Skip to content

Reactividad ​

La reactividad es el corazón de Vue: cuando un dato cambia, todo lo que depende de él (la pantalla, valores calculados, efectos) se actualiza solo. Aprenderás las cuatro herramientas principales de la Composition API: ref, reactive, computed y watch (más watchEffect), y cuándo usar cada una.

Concepto ​

Vue envuelve tus datos en objetos especiales (proxies) que detectan cuándo los lees y cuándo los cambias:

  1. Cuando la plantilla o una computada lee un dato, Vue anota la dependencia.
  2. Cuando ese dato cambia, Vue vuelve a ejecutar sólo lo que dependía de él.

Analogía: es como suscribirte a un canal. La plantilla "se suscribe" a los datos que usa y recibe una notificación cuando cambian. Lo que no usa ese dato no se entera ni se vuelve a dibujar.

HerramientaPara qué
refUn valor reactivo (cualquier tipo). La opción por defecto
reactiveUn objeto reactivo (sin .value)
computedUn valor derivado de otros, con caché
watchEjecutar código cuando un dato específico cambia (efectos secundarios)
watchEffectEjecutar código que se re-ejecuta con cualquier dato que use

ref ​

ref crea un contenedor reactivo para cualquier valor: número, string, booleano, arreglo, objeto.

vue
<script setup>
import { ref } from 'vue'

const contador = ref(0)
const nombre = ref('Ana')
const productos = ref([])
const usuario = ref({ nombre: 'Luis', direccion: { ciudad: 'CDMX' } })

function incrementar() {
  // En el <script>, se accede y modifica con .value
  contador.value++
}

function agregarProducto() {
  // Los arreglos y objetos dentro de un ref también son reactivos (profundos)
  productos.value.push({ id: Date.now(), nombre: 'Nuevo' })
  usuario.value.direccion.ciudad = 'Guadalajara'
}
</script>

<template>
  <!-- En la plantilla, .value NO se escribe: Vue lo desenvuelve solo -->
  <button @click="incrementar">{{ contador }}</button>
  <p>{{ nombre }} · {{ usuario.direccion.ciudad }} · {{ productos.length }} productos</p>
  <button @click="agregarProducto">Agregar</button>
</template>

Error común: olvidar .value

js
const contador = ref(0)
contador++          // ❌ Error: intentas sumar al objeto ref
contador.value++    // ✅

En el script siempre .value; en el template nunca. La extensión Vue - Official de VS Code te avisa si te equivocas.

A diferencia de React, en Vue sí puedes mutar arreglos y objetos directamente (push, asignar propiedades): Vue detecta el cambio.

reactive ​

reactive hace reactivo un objeto completo, sin necesidad de .value:

vue
<script setup>
import { reactive } from 'vue'

const formulario = reactive({
  nombre: '',
  email: '',
  intereses: []
})

const carrito = reactive({
  items: [],
  cupon: null
})

function agregar(producto) {
  carrito.items.push({ ...producto, cantidad: 1 }) // sin .value
}
</script>

<template>
  <input v-model="formulario.nombre" placeholder="Nombre">
  <input v-model="formulario.email" placeholder="Email">
  <p>{{ formulario.nombre }} — {{ formulario.email }}</p>
  <button @click="agregar({ id: 1, nombre: 'Café' })">Agregar ({{ carrito.items.length }})</button>
</template>

Limitaciones de reactive ​

js
import { reactive, toRefs } from 'vue'

let estado = reactive({ contador: 0 })

// ❌ 1. Reemplazar el objeto completo rompe la reactividad
estado = reactive({ contador: 1 }) // la plantilla sigue viendo el objeto viejo

// ❌ 2. Desestructurar pierde la reactividad
const { contador } = estado // "contador" es sólo el número 0, ya no reactivo

// ✅ toRefs convierte cada propiedad en un ref conectado al objeto
const { contador: contadorRef } = toRefs(estado)
contadorRef.value++ // actualiza estado.contador

// ❌ 3. No funciona con primitivos
const numero = reactive(5) // no es reactivo (y Vue avisa)

¿ref o reactive?

La documentación oficial recomienda usar ref por defecto: funciona con cualquier tipo, se puede reemplazar completo (lista.value = nuevaLista) y se puede desestructurar o pasar a funciones sin perder reactividad. reactive es cómodo para objetos de formulario agrupados que nunca reemplazas.

computed ​

Una propiedad computada deriva un valor de otros datos reactivos. Vue la cachea: sólo se recalcula cuando cambian sus dependencias.

vue
<script setup>
import { ref, computed } from 'vue'

const items = ref([
  { id: 1, nombre: 'Café de Chiapas', precio: 249, cantidad: 2 },
  { id: 2, nombre: 'Taza de barro', precio: 120, cantidad: 1 }
])
const cupon = ref('')

// Valores derivados: siempre correctos, nunca desincronizados
const subtotal = computed(() => items.value.reduce((s, i) => s + i.precio * i.cantidad, 0))
const descuento = computed(() => (cupon.value.toUpperCase() === 'BIENVENIDA10' ? subtotal.value * 0.1 : 0))
const envio = computed(() => (subtotal.value - descuento.value >= 999 ? 0 : 149))
const total = computed(() => subtotal.value - descuento.value + envio.value)
const piezas = computed(() => items.value.reduce((n, i) => n + i.cantidad, 0))

const moneda = (n) => n.toLocaleString('es-MX', { style: 'currency', currency: 'MXN' })
</script>

<template>
  <div class="carrito">
    <div v-for="i in items" :key="i.id" class="fila">
      <span>{{ i.nombre }}</span>
      <input v-model.number="i.cantidad" type="number" min="1">
      <span>{{ moneda(i.precio * i.cantidad) }}</span>
    </div>
    <input v-model="cupon" placeholder="Cupón (BIENVENIDA10)">
    <dl>
      <dt>Piezas</dt><dd>{{ piezas }}</dd>
      <dt>Subtotal</dt><dd>{{ moneda(subtotal) }}</dd>
      <dt v-if="descuento">Descuento</dt><dd v-if="descuento">−{{ moneda(descuento) }}</dd>
      <dt>Envío</dt><dd>{{ envio === 0 ? 'Gratis' : moneda(envio) }}</dd>
      <dt><strong>Total</strong></dt><dd><strong>{{ moneda(total) }}</strong></dd>
    </dl>
  </div>
</template>

<style scoped>
.carrito { max-width: 420px; font-family: system-ui; }
.fila { display: flex; gap: 0.5rem; align-items: center; justify-content: space-between; margin-bottom: 0.5rem; }
.fila input { width: 60px; }
dl { display: grid; grid-template-columns: 1fr auto; gap: 0.25rem; }
dd { margin: 0; text-align: right; }
</style>

computed vs función ​

js
const totalComputado = computed(() => calcularTotal(items.value)) // cacheado
function totalFuncion() { return calcularTotal(items.value) }    // se ejecuta en cada render

Usa computed para valores derivados (especialmente si son costosos o se usan varias veces). Las computadas deben ser puras: sin peticiones, sin modificar otros datos, sin efectos.

Computadas con escritura ​

js
const nombre = ref('Ana')
const apellido = ref('López')

const nombreCompleto = computed({
  get: () => `${nombre.value} ${apellido.value}`,
  set: (valor) => {
    const [n, ...resto] = valor.split(' ')
    nombre.value = n
    apellido.value = resto.join(' ')
  }
})

nombreCompleto.value = 'Luis Pérez Díaz' // actualiza nombre y apellido

watch ​

watch ejecuta una función cuando un dato específico cambia. Es para efectos secundarios: guardar en localStorage, pedir datos a una API, registrar analítica, sincronizar con algo externo.

vue
<script setup>
import { ref, watch } from 'vue'

const busqueda = ref('')
const resultados = ref([])
const cargando = ref(false)
const tema = ref(localStorage.getItem('tema') ?? 'claro')

// 1. Observar un ref: recibe (valorNuevo, valorAnterior)
watch(tema, (nuevo, anterior) => {
  console.log(`Tema: ${anterior} → ${nuevo}`)
  localStorage.setItem('tema', nuevo)
  document.documentElement.dataset.tema = nuevo
}, { immediate: true }) // immediate: también se ejecuta al inicio

// 2. Buscar con debounce y cancelar la búsqueda anterior
watch(busqueda, async (texto, _anterior, onCleanup) => {
  if (texto.trim().length < 2) {
    resultados.value = []
    return
  }
  const controlador = new AbortController()
  let cancelado = false
  onCleanup(() => {           // se ejecuta si busqueda cambia antes de terminar
    cancelado = true
    controlador.abort()
  })

  await new Promise((r) => setTimeout(r, 400)) // debounce
  if (cancelado) return

  cargando.value = true
  try {
    const res = await fetch(`https://dummyjson.com/products/search?q=${encodeURIComponent(texto)}&limit=5&select=title`, {
      signal: controlador.signal
    })
    resultados.value = (await res.json()).products
  } catch (e) {
    if (e.name !== 'AbortError') console.error(e)
  } finally {
    if (!cancelado) cargando.value = false
  }
})
</script>

<template>
  <button @click="tema = tema === 'claro' ? 'oscuro' : 'claro'">Tema: {{ tema }}</button>
  <input v-model="busqueda" placeholder="Busca: phone, laptop…">
  <p v-if="cargando">Buscando…</p>
  <ul>
    <li v-for="r in resultados" :key="r.id">{{ r.title }}</li>
  </ul>
</template>

Qué se puede observar ​

js
const usuario = ref({ nombre: 'Ana', direccion: { ciudad: 'CDMX' } })
const filtros = reactive({ categoria: '', orden: 'precio' })

// Un ref
watch(contador, (n) => console.log(n))

// Una propiedad: usa una función getter
watch(() => usuario.value.direccion.ciudad, (ciudad) => console.log('Nueva ciudad:', ciudad))

// Varias fuentes a la vez
watch([() => filtros.categoria, () => filtros.orden], ([cat, orden]) => {
  console.log('Filtros:', cat, orden)
})

// Un objeto completo (cualquier cambio interno): deep
watch(usuario, (u) => localStorage.setItem('usuario', JSON.stringify(u)), { deep: true })

// Un objeto reactive se observa en profundidad automáticamente
watch(filtros, () => console.log('Cambió algún filtro'))

// Ejecutar sólo una vez (Vue 3.4+)
watch(contador, () => console.log('Primer cambio'), { once: true })

watchEffect ​

watchEffect se ejecuta de inmediato y rastrea automáticamente todos los datos reactivos que lee. Útil cuando el efecto depende de varios datos:

js
import { ref, watchEffect } from 'vue'

const pagina = ref(1)
const categoria = ref('laptops')
const productos = ref([])

watchEffect(async (onCleanup) => {
  const controlador = new AbortController()
  onCleanup(() => controlador.abort())

  // Lee pagina y categoria → se re-ejecuta cuando cualquiera cambie
  const skip = (pagina.value - 1) * 10
  const res = await fetch(`https://dummyjson.com/products/category/${categoria.value}?limit=10&skip=${skip}`, {
    signal: controlador.signal
  }).catch(() => null)
  if (res?.ok) productos.value = (await res.json()).products
})

watchEffect y await

watchEffect sólo rastrea las dependencias leídas antes del primer await. En el ejemplo, pagina y categoria se leen antes del fetch, así que funciona. Si las lees después de un await, no se rastrearán: en ese caso usa watch con fuentes explícitas.

watchwatchEffect
DependenciasExplícitasAutomáticas
Se ejecuta al inicioNo (salvo immediate: true)Sí
Valor anteriorSíNo
Úsalo cuandoTe importa qué cambióSólo quieres sincronizar con lo que uses

Ciclo de vida ​

Hooks para ejecutar código en momentos concretos del componente:

vue
<script setup>
import { ref, onMounted, onUnmounted, onUpdated } from 'vue'

const ancho = ref(0)

function medir() {
  ancho.value = window.innerWidth
}

// Después de insertarse en el DOM (aquí ya existe window/document y los elementos)
onMounted(() => {
  medir()
  window.addEventListener('resize', medir)
})

// Antes de destruirse: limpia listeners, intervalos, suscripciones
onUnmounted(() => {
  window.removeEventListener('resize', medir)
})

onUpdated(() => {
  // después de cada actualización del DOM (rara vez necesario)
})
</script>

<template>
  <p>Ancho de la ventana: {{ ancho }}px</p>
</template>

Refs de plantilla: acceder al DOM ​

vue
<script setup>
import { onMounted, useTemplateRef } from 'vue'

// Vue 3.5+: useTemplateRef conecta con ref="busqueda" en la plantilla
const input = useTemplateRef('busqueda')

onMounted(() => input.value.focus()) // enfoca el input al cargar
</script>

<template>
  <input ref="busqueda" placeholder="Empieza a escribir…">
</template>

(En versiones anteriores a 3.5 se usaba const busqueda = ref(null) con el mismo nombre que el atributo ref.)

Buena práctica

  • Prefiere computed sobre watch siempre que puedas calcular algo. Usa watch sólo para efectos secundarios.
  • Usa ref por defecto y reactive para objetos agrupados que no reemplazarás.
  • Limpia siempre lo que crees en onMounted (listeners, intervalos) en onUnmounted.

Error común

Usar watch para mantener un valor derivado:

js
// ❌
const total = ref(0)
watch(items, () => { total.value = items.value.reduce(...) }, { deep: true })

// ✅
const total = computed(() => items.value.reduce(...))

Ejercicios ​

  1. Crea un conversor de temperatura con dos inputs (°C y °F) enlazados: al escribir en uno, el otro se actualiza. Usa una computada con get/set.
  2. Crea un componente de notas que guarde automáticamente en localStorage (con watch), cargue lo guardado al iniciar, y muestre con una computada el número de palabras y caracteres.
Ver solución
vue
<!-- 1. ConversorTemperatura.vue -->
<script setup>
import { ref, computed } from 'vue'

const celsius = ref(25)

const fahrenheit = computed({
  get: () => +(celsius.value * 9 / 5 + 32).toFixed(1),
  set: (f) => { celsius.value = +((f - 32) * 5 / 9).toFixed(1) }
})
</script>

<template>
  <label>°C <input v-model.number="celsius" type="number"></label>
  <label>°F <input v-model.number="fahrenheit" type="number"></label>
  <p>{{ celsius }} °C = {{ fahrenheit }} °F</p>
</template>
vue
<!-- 2. NotasAutoguardado.vue -->
<script setup>
import { ref, computed, watch } from 'vue'

const CLAVE = 'mis-notas'
const texto = ref(localStorage.getItem(CLAVE) ?? '')
const guardado = ref(true)

let temporizador
watch(texto, (nuevo) => {
  guardado.value = false
  clearTimeout(temporizador)
  temporizador = setTimeout(() => {
    localStorage.setItem(CLAVE, nuevo)
    guardado.value = true
  }, 500)
})

const palabras = computed(() => texto.value.trim().split(/\s+/).filter(Boolean).length)
const caracteres = computed(() => texto.value.length)
</script>

<template>
  <textarea v-model="texto" rows="8" cols="50" placeholder="Escribe tus notas…"></textarea>
  <p>{{ palabras }} palabras · {{ caracteres }} caracteres · {{ guardado ? '✓ Guardado' : 'Guardando…' }}</p>
</template>

Resumen ​

  • ref crea valores reactivos (con .value en el script, sin él en la plantilla); reactive hace reactivo un objeto.
  • computed deriva valores con caché; deben ser puras.
  • watch ejecuta efectos cuando cambian fuentes concretas; watchEffect rastrea dependencias automáticamente.
  • onMounted/onUnmounted manejan el ciclo de vida; useTemplateRef accede a elementos del DOM.

Academia Web — aprende, copia y construye.