Tema
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:
- Cuando la plantilla o una computada lee un dato, Vue anota la dependencia.
- 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.
| Herramienta | Para qué |
|---|---|
ref | Un valor reactivo (cualquier tipo). La opción por defecto |
reactive | Un objeto reactivo (sin .value) |
computed | Un valor derivado de otros, con caché |
watch | Ejecutar código cuando un dato específico cambia (efectos secundarios) |
watchEffect | Ejecutar 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 renderUsa 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 apellidowatch
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.
watch | watchEffect | |
|---|---|---|
| Dependencias | Explícitas | Automáticas |
| Se ejecuta al inicio | No (salvo immediate: true) | Sí |
| Valor anterior | Sí | No |
| Úsalo cuando | Te 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
computedsobrewatchsiempre que puedas calcular algo. Usawatchsólo para efectos secundarios. - Usa
refpor defecto yreactivepara objetos agrupados que no reemplazarás. - Limpia siempre lo que crees en
onMounted(listeners, intervalos) enonUnmounted.
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
- 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. - Crea un componente de notas que guarde automáticamente en
localStorage(conwatch), 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
refcrea valores reactivos (con.valueen el script, sin él en la plantilla);reactivehace reactivo un objeto.computedderiva valores con caché; deben ser puras.watchejecuta efectos cuando cambian fuentes concretas;watchEffectrastrea dependencias automáticamente.onMounted/onUnmountedmanejan el ciclo de vida;useTemplateRefaccede a elementos del DOM.