En esta página

computed() y watchers — estado derivado y efectos

14 min lectura TextoCap. 2 — Reactividad

computed() — estado derivado cacheado

computed() crea un valor reactivo que se deriva de otros datos reactivos. Vue rastrea automáticamente las dependencias y recalcula el valor solo cuando alguna de ellas cambia.

import { ref, computed } from 'vue'

const temperatura = ref(25) // Celsius

// Se recalcula automáticamente cuando temperatura.value cambia
const fahrenheit = computed(() => temperatura.value * 9/5 + 32)
const kelvin = computed(() => temperatura.value + 273.15)

console.log(fahrenheit.value) // 77
temperatura.value = 30
console.log(fahrenheit.value) // 86 — recalculado automáticamente

Por qué usar computed en lugar de una función

Ambos enfoques parecen equivalentes, pero tienen una diferencia crucial:

<script setup lang="ts">
const lista = ref([1, 2, 3, 4, 5])

// ❌ Función — se ejecuta en CADA rerenderizado
function totalFuncion() {
  console.log('Recalculando...')
  return lista.value.reduce((a, b) => a + b, 0)
}

// ✅ computed — se ejecuta solo cuando lista.value cambia
const totalComputed = computed(() => {
  console.log('Recalculando...')
  return lista.value.reduce((a, b) => a + b, 0)
})
</script>

<template>
  <!-- totalFuncion() se llama en cada rerenderizado aunque lista no cambie -->
  <!-- totalComputed se usa desde caché si lista no cambió -->
  <p>{{ totalFuncion() }} vs {{ totalComputed }}</p>
</template>

Si el componente rerenderiza por cualquier razón (un prop no relacionado cambió, el padre rerenderizó), totalFuncion() se ejecuta de nuevo mientras que totalComputed devuelve el valor cacheado.

Computed con TypeScript

Vue infiere el tipo de retorno automáticamente, pero puedes ser explícito:

// Inferencia automática — Computed<number>
const doble = computed(() => contador.value * 2)

// Tipo explícito cuando TypeScript necesita ayuda
const elemento = computed<HTMLElement | null>(() =>
  document.getElementById('mi-elemento')
)

Computed de escritura (writable computed)

Por defecto, los computed son de solo lectura. Puedes hacerlos de escritura pasando un objeto con get y set:

const grados = ref(0)

const radianes = computed({
  get: () => grados.value * (Math.PI / 180),
  set: (rad: number) => {
    grados.value = rad * (180 / Math.PI)
  }
})

// Ahora puedes escribir también
radianes.value = Math.PI / 2  // grados.value se convierte en 90

Los computed de escritura son especialmente útiles para inputs enlazados con v-model que necesitan transformar datos.

watch() — observar cambios específicos

watch() ejecuta una función callback cuando una fuente específica cambia. A diferencia de watchEffect, es lazy por defecto (no se ejecuta al inicio) y recibe el valor nuevo y el anterior:

import { ref, watch } from 'vue'

const contador = ref(0)

// Fuente simple — ref
watch(contador, (nuevo, anterior) => {
  console.log(`Cambió de ${anterior} a ${nuevo}`)
})

// Función getter — para propiedades de reactive
const estado = reactive({ pagina: 1 })
watch(
  () => estado.pagina,
  (nuevaPagina) => {
    cargarDatos(nuevaPagina)
  }
)

Opciones de watch()

watch(fuente, callback, {
  immediate: true,   // Ejecutar inmediatamente al montar (no lazy)
  deep: true,        // Observar cambios profundos en objetos/arrays
  once: true,        // Ejecutar solo una vez y luego detenerse
  flush: 'post',     // 'pre' (default), 'post' (después del DOM), 'sync'
})

Observar múltiples fuentes

const x = ref(0)
const y = ref(0)

// Array de fuentes — callback recibe arrays de valores
watch([x, y], ([nuevoX, nuevoY], [anteriorX, anteriorY]) => {
  console.log(`x: ${anteriorX}→${nuevoX}, y: ${anteriorY}→${nuevoY}`)
})

Deep watching

Para observar cambios profundos dentro de un objeto:

const usuario = reactive({
  nombre: 'Ana',
  direccion: { ciudad: 'Madrid', codigo: '28001' }
})

// Sin deep: true, no detecta cambios en propiedades anidadas
watch(
  () => usuario,
  (val) => console.log('Usuario cambió', val),
  { deep: true }
)

// Mejor alternativa: observar la propiedad específica
watch(
  () => usuario.direccion.ciudad,
  (ciudad) => console.log('Ciudad:', ciudad)
)

Limpieza con watch

Cuando un watcher necesita hacer operaciones asíncronas, puede haber condiciones de carrera. La función de limpieza se ejecuta antes de que el callback se vuelva a llamar:

watch(busqueda, (nueva, _anterior, onCleanup) => {
  let cancelado = false

  fetchResultados(nueva).then(datos => {
    if (!cancelado) resultados.value = datos
  })

  onCleanup(() => {
    cancelado = true // Cancela la solicitud anterior si busqueda cambia
  })
})

watchEffect() — rastreo automático de dependencias

watchEffect() ejecuta una función inmediatamente y rastrea automáticamente qué datos reactivos usa. Se vuelve a ejecutar cuando cualquiera de esos datos cambia:

import { watchEffect } from 'vue'

const usuario = ref<string | null>(null)
const pagina = ref(1)

// Se ejecuta inmediatamente y cuando usuario o pagina cambian
watchEffect(async () => {
  if (!usuario.value) return
  const datos = await fetch(`/api/users/${usuario.value}/posts?page=${pagina.value}`)
  // Procesar datos...
})

watchEffect con limpieza

watchEffect((onCleanup) => {
  const subscription = storeDeEventos.suscribir(handler)

  // Se llama antes de cada re-ejecución y al desmontar
  onCleanup(() => subscription.cancelar())
})

Detener un watcher

Tanto watch como watchEffect devuelven una función para detenerlos:

const detener = watchEffect(() => {
  console.log(contador.value)
})

// Más tarde...
detener() // El watcher deja de rastrear

Los watchers creados dentro de setup() o script setup se detienen automáticamente cuando el componente se desmonta. Los watchers creados fuera del ciclo de vida deben detenerse manualmente.

Patrones comunes con watchers

Debounce de búsqueda

import { ref, watch } from 'vue'

const busqueda = ref('')
const resultados = ref<string[]>([])
let timeout: ReturnType<typeof setTimeout>

watch(busqueda, (nueva) => {
  clearTimeout(timeout)
  timeout = setTimeout(async () => {
    resultados.value = await buscarAPI(nueva)
  }, 300)
})

Persistir estado en localStorage

import { ref, watch } from 'vue'

function useLocalStorage<T>(clave: string, valorInicial: T) {
  const guardado = localStorage.getItem(clave)
  const estado = ref<T>(guardado ? JSON.parse(guardado) as T : valorInicial)

  watch(estado, (nuevo) => {
    localStorage.setItem(clave, JSON.stringify(nuevo))
  }, { deep: true })

  return estado
}

const preferencias = useLocalStorage('prefs', { tema: 'claro', idioma: 'es' })

Sincronizar con una API externa

const idProducto = ref<number | null>(null)
const producto = ref<Producto | null>(null)

watch(idProducto, async (id) => {
  if (id === null) {
    producto.value = null
    return
  }
  producto.value = await fetchProducto(id)
}, { immediate: true }) // Cargar inmediatamente si idProducto ya tiene valor

Práctica

  1. Carrito de compras con computed: Crea un array reactivo de productos con precio y cantidad. Usa computed() para calcular el subtotal, el IVA (21%) y el total. Añade un descuento reactivo y muestra todos los valores en una tabla.
  2. Búsqueda con debounce: Implementa un input de búsqueda con watch() que haga una petición a https://jsonplaceholder.typicode.com/users?name_like={busqueda} con 400ms de debounce. Muestra los resultados y un indicador de carga.
  3. Sincronización con localStorage: Crea un formulario de preferencias (tema, idioma, tamaño de fuente) usando reactive(). Usa watch() con deep: true para guardar automáticamente en localStorage y restaurar al montar el componente.

En la siguiente lección aprenderemos a comunicar componentes mediante props y emits.

computed() es cacheado — úsalo siempre para estado derivado
A diferencia de llamar a una función en el template (que se ejecuta en cada rerenderizado), computed() cachea el resultado y solo lo recalcula cuando sus dependencias cambian. Si tienes lógica derivada de datos reactivos, computed() siempre es la opción correcta.
No causes efectos secundarios en computed()
Los computed deben ser funciones puras que solo leen datos reactivos y devuelven un valor derivado. Nunca hagas fetch, mutaciones, o acciones asíncronas dentro de computed(). Para eso existen watch() y watchEffect().
watch vs watchEffect — cuándo usar cada uno
Usa watch() cuando necesitas saber el valor anterior, controlar exactamente qué observar, o tener acceso lazy (no ejecutar inmediatamente). Usa watchEffect() cuando quieres que el efecto se ejecute inmediatamente y rastrear las dependencias automáticamente.
vue
<script setup lang="ts">
import { ref, computed } from 'vue'

const precio = ref(100)
const cantidad = ref(3)
const descuento = ref(0.1) // 10%

// computed — valor derivado cacheado
const subtotal = computed(() => precio.value * cantidad.value)

const totalConDescuento = computed(() =>
  subtotal.value * (1 - descuento.value)
)

const resumen = computed(() =>
  `${cantidad.value} items × $${precio.value} = $${subtotal.value.toFixed(2)}`
)

// computed de escritura (writable computed)
const nombreCompleto = ref('Ana García')

const nombre = computed({
  get: () => nombreCompleto.value.split(' ')[0],
  set: (nuevoNombre: string) => {
    const apellido = nombreCompleto.value.split(' ')[1] ?? ''
    nombreCompleto.value = `${nuevoNombre} ${apellido}`.trim()
  }
})
</script>

<template>
  <div>
    <p>{{ resumen }}</p>
    <p>Total con {{ descuento * 100 }}% descuento: ${{ totalConDescuento.toFixed(2) }}</p>

    <label>
      Nombre (editable):
      <input v-model="nombre" />
    </label>
    <p>Nombre completo: {{ nombreCompleto }}</p>
  </div>
</template>
vue
<script setup lang="ts">
import { ref, reactive, watch, watchEffect, onUnmounted } from 'vue'

const busqueda = ref('')
const resultados = ref<string[]>([])
const cargando = ref(false)

const filtros = reactive({ categoria: 'todos', orden: 'asc' })

// watch — observa fuentes específicas
watch(busqueda, async (nuevo, anterior) => {
  if (nuevo === anterior) return
  cargando.value = true
  // Simula una llamada API
  await new Promise(r => setTimeout(r, 300))
  resultados.value = nuevo
    ? [`Resultado para "${nuevo}" #1`, `Resultado para "${nuevo}" #2`]
    : []
  cargando.value = false
}, { debounce: 300 })

// Observar múltiples fuentes
watch(
  [busqueda, () => filtros.categoria],
  ([nuevaBusqueda, nuevaCategoria]) => {
    console.log('Cambio:', nuevaBusqueda, nuevaCategoria)
  }
)

// watchEffect — rastrea dependencias automáticamente
const cleanup = watchEffect((onCleanup) => {
  const timer = setInterval(() => {
    console.log('tick con busqueda:', busqueda.value)
  }, 1000)

  // Limpieza cuando el efecto se vuelve a ejecutar o se desmonta
  onCleanup(() => clearInterval(timer))
})

// Detener el watcher manualmente
onUnmounted(() => cleanup())
</script>

<template>
  <div>
    <input v-model="busqueda" placeholder="Buscar..." />
    <p v-if="cargando">Buscando...</p>
    <ul v-else>
      <li v-for="r in resultados" :key="r">{{ r }}</li>
    </ul>
  </div>
</template>