Todos los artículos

watch vs. watchEffect en Vue 3: evita cotizaciones de envío desactualizadas

Construye un cotizador de envíos con Vue 3.5+: usa watch, debounce y onWatcherCleanup para evitar tarifas obsoletas en un checkout real.

LA

Luis Aldair Quispe Rios

7 min de lectura
Ilustración de un cotizador con varias solicitudes; dos respuestas antiguas se cancelan y solo la más reciente llega al resultado.

En un checkout, la persona elige un destino y cambia el peso del paquete. La interfaz pide una tarifa al servidor cada vez que esos datos cambian. Parece sencillo hasta que dos respuestas llegan en un orden distinto al de las solicitudes: la tarifa de Cusco tarda 900 ms y la de Lima tarda 120 ms. Si el usuario cambia de Cusco a Lima rápidamente, la respuesta antigua puede sobrescribir la nueva. La pantalla termina mostrando un precio para el destino equivocado.

Este artículo construye un cotizador pequeño pero reproducible. Usaremos watch para declarar exactamente qué cambios disparan la consulta, un retraso corto para no consultar por cada pulsación, AbortController para cancelar la petición anterior y onWatcherCleanup para retirar también cualquier respuesta tardía. El ejemplo requiere Vue 3.5 o superior y funciona en una aplicación Vue con un endpoint HTTP; al final incluyo un endpoint de demostración para Nuxt.

El problema: la última respuesta no siempre es la correcta

Supongamos que el peso es de 1 kg:

  1. Seleccionas Cusco y se inicia la solicitud A.

  2. Cambias a Lima y se inicia la solicitud B.

  3. B responde primero: la interfaz muestra S/ 12.00.

  4. A responde después: sin protección, la interfaz cambia a S/ 20.00, aunque el destino visible sigue siendo Lima.

El problema no es la velocidad de Vue. Es una condición de carrera entre operaciones asíncronas. La solución debe cancelar el trabajo anterior y comprobar que una respuesta aún pertenece a los datos actuales antes de pintarla.

¿Por qué watch aquí y no watchEffect?

watch observa fuentes explícitas. Nuestro precio solo depende del destino y del peso; leer el estado de carga o el resultado dentro del callback no debe generar otra consulta. watchEffect detecta automáticamente las dependencias reactivas que se leen durante su ejecución sincrónica. Es útil cuando queremos que un efecto siga todas esas dependencias, pero aquí la lista explícita expresa mejor la regla del negocio.

computed cumple otro papel: derivar un valor sin producir un efecto externo. Una llamada HTTP es un efecto, por eso usamos un watcher.

Necesitas…

Usa…

Calcular un valor a partir de otros valores reactivos

computed

Llamar a una API cuando cambian fuentes concretas

watch

Ejecutar un efecto inmediato que siga automáticamente las lecturas reactivas sincrónicas

watchEffect

Hay un detalle importante: en un callback async de watchEffect, las lecturas reactivas realizadas después del primer await no se registran como dependencias. Para este cotizador, declarar las fuentes con watch evita esa ambigüedad.

El componente completo

El endpoint devolverá { amount, currency, etaDays }. En una aplicación real, el servidor consultaría la transportadora o aplicaría sus reglas de tarifa. Aquí nos concentramos en que la interfaz nunca mezcle una respuesta antigua con la selección actual.

<script setup lang="ts">
import { onWatcherCleanup, ref, watch } from 'vue'

type Destination = '' | 'lima' | 'cusco'
type Quote = { amount: number; currency: 'PEN'; etaDays: number }
type Status = 'idle' | 'loading' | 'success' | 'error'

const destination = ref<Destination>('')
const weightKg = ref(1)
const quote = ref<Quote | null>(null)
const status = ref<Status>('idle')
const money = new Intl.NumberFormat('es-PE', {
  style: 'currency',
  currency: 'PEN',
})

watch([destination, weightKg], ([zone, kg]) => {
  // Nunca dejamos visible la tarifa del destino anterior.
  quote.value = null

  if (!zone || !Number.isFinite(kg) || kg <= 0) {
    status.value = 'idle'
    return
  }

  status.value = 'loading'
  const controller = new AbortController()
  let current = true

  // Agrupa cambios rápidos, por ejemplo al editar el peso.
  const timer = setTimeout(async () => {
    try {
      const query = new URLSearchParams({
        destination: zone,
        weightKg: String(kg),
      })
      const response = await fetch(`/api/shipping/quote?${query}`, {
        signal: controller.signal,
      })

      if (!response.ok) throw new Error(`HTTP ${response.status}`)
      const result = (await response.json()) as Quote

      // Protege también frente a clientes que no respeten la cancelación.
      if (!current) return
      quote.value = result
      status.value = 'success'
    } catch {
      // Una cancelación esperada no es un error para el usuario.
      if (!current || controller.signal.aborted) return
      status.value = 'error'
    }
  }, 250)

  // Debe registrarse durante la ejecución sincrónica del watcher.
  onWatcherCleanup(() => {
    current = false
    clearTimeout(timer)
    controller.abort()
  })
})
</script>

<template>
  <section aria-labelledby="shipping-title">
    <h2 id="shipping-title">Cotiza tu envío</h2>

    <label for="destination">Destino</label>
    <select id="destination" v-model="destination">
      <option value="">Selecciona un destino</option>
      <option value="cusco">Cusco</option>
      <option value="lima">Lima</option>
    </select>

    <label for="weight">Peso del paquete (kg)</label>
    <input id="weight" v-model.number="weightKg" type="number" min="0.1" step="0.1" />

    <p v-if="status === 'idle'">Selecciona un destino y un peso válido.</p>
    <p v-else-if="status === 'loading'" role="status">Calculando tarifa…</p>
    <p v-else-if="status === 'error'" role="alert">
      No pudimos calcular el envío. Cambia un dato para intentarlo de nuevo.
    </p>
    <p v-else-if="quote" role="status">
      Envío: {{ money.format(quote.amount) }} · Entrega estimada: {{ quote.etaDays }} día(s)
    </p>
  </section>
</template>

El orden dentro del watcher importa. Primero borramos la tarifa vieja. Después validamos los datos. Solo entonces iniciamos el temporizador y registramos la limpieza. Cuando cambia el destino o el peso, Vue ejecuta esa limpieza antes de volver a correr el watcher: cancela el temporizador si aún no se disparó, aborta la petición si ya empezó y marca su respuesta como inválida.

El booleano current parece redundante junto a AbortController, pero protege el momento entre recibir una respuesta y actualizar la interfaz, y sirve si sustituyes fetch por una biblioteca que no propaga bien la cancelación. En el catch ignoramos las cancelaciones; los errores reales sí muestran un estado visible.

Un endpoint de demostración para reproducir la carrera

Si usas Nuxt, crea server/api/shipping/quote.get.ts. Las tarifas y las demoras son simuladas: permiten comprobar el comportamiento sin contratar un servicio de envíos. Sustituye esta lógica por tu proveedor o reglas de negocio cuando integres el checkout.

export default defineEventHandler(async (event) => {
  const query = getQuery(event)
  const destination = String(query.destination ?? '')
  const weightKg = Number(query.weightKg)

  if (
    !['lima', 'cusco'].includes(destination) ||
    !Number.isFinite(weightKg) ||
    weightKg <= 0
  ) {
    throw createError({ statusCode: 400, statusMessage: 'Datos de envío inválidos' })
  }

  // Cusco tarda más a propósito para exponer las respuestas fuera de orden.
  await new Promise((resolve) => setTimeout(resolve, destination === 'cusco' ? 900 : 120))

  const base = destination === 'cusco' ? 18 : 10
  return {
    amount: Math.round((base + weightKg * 2) * 100) / 100,
    currency: 'PEN',
    etaDays: destination === 'cusco' ? 3 : 1,
  }
})

Para verificarlo, elige Cusco, espera alrededor de 300 ms para que empiece la petición y cambia a Lima. Con 1 kg, el resultado final debe ser S/ 12.00 y 1 día, incluso después de que habría terminado la solicitud lenta de Cusco. Repite cambiando el peso varias veces: solo la última combinación debe producir una tarifa visible. En la pestaña Network del navegador podrás ver las peticiones anteriores canceladas.

Dos límites que importan en producción

Cancelar en el navegador no garantiza que el servidor haya detenido su trabajo. El objetivo principal de este patrón es mantener correcta la interfaz y evitar trabajo innecesario cuando sea posible. El backend debe validar los parámetros y calcular de nuevo la tarifa al confirmar la compra; nunca debe confiar en el importe mostrado por el cliente.

onWatcherCleanup requiere Vue 3.5+ y debe registrarse antes de cualquier await. Si tu proyecto usa una versión anterior, recibe onCleanup como tercer argumento de watch y registra allí la misma función. Ese argumento también evita la restricción de registro sincrónico de onWatcherCleanup.

Este patrón sirve además para disponibilidad de inventario por almacén, precios según moneda o fechas y estimaciones de entrega. La idea común es sencilla: cuando cambian los datos de entrada, la respuesta anterior pierde el derecho de actualizar la pantalla.

Lecturas recomendadas

Si estás empezando con la Composition API, puedes leer primero ¿Qué es un Composable? en El Rincón de Vue.

Animación de nueve segundos: Cusco inicia una consulta lenta, el usuario cambia a Lima y la tarifa final permanece en S/ 12.00.
Animación de nueve segundos: Cusco inicia una consulta lenta, el usuario cambia a Lima y la tarifa final permanece en S/ 12.00.

Sigue leyendo

¿Y si Vue.js también hablara Web Components? La magia de defineCustomElement

¿Y si Vue.js también hablara Web Components? La magia de defineCustomElement

Si eres fan de Vue.js como yo, seguramente has creado componentes reutilizables una y otra vez. Pero… ¿alguna vez te preguntaste cómo compartir tus componentes Vue fuera de una app Vue? ¿Qué pasaría si pudieras usarlos en React, Angular o incluso en HTML puro? La respuesta está en los Web Components y Vue tiene una herramienta poderosa para eso: defineCustomElement. Hoy te voy a contar cómo Vue y los Web Components pueden ser mejores amigos. Te prometo que al final vas a querer probarlo en tu próximo proyecto (o experimento).
 Potencia tu app Vue con Firebase + VueFire

Potencia tu app Vue con Firebase + VueFire

¿Qué es VueFire? VueFire te permite conectar tu app Vue 3 con Firebase (Firestore, Auth, RTDB, Storage) de forma sencilla y reactiva usando composables como useAuth, useFirestore, etc. Ideal si quieres apps en tiempo real sin escribir cientos de líneas de lógica.
Guía práctica de Vue 3: Cómo usar <Teleport> para modales, tooltips y notificaciones adaptativas

Guía práctica de Vue 3: Cómo usar <Teleport> para modales, tooltips y notificaciones adaptativas

¿Sabías que puedes mover parte del DOM de un componente Vue a otro lugar del HTML sin romper la reactividad? En esta guía aprenderás cómo usar <Teleport> en Vue 3, desde lo básico hasta casos reales como notificaciones, menús contextuales y overlays.