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:
Seleccionas Cusco y se inicia la solicitud A.
Cambias a Lima y se inicia la solicitud B.
B responde primero: la interfaz muestra S/ 12.00.
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 |
|
Llamar a una API cuando cambian fuentes concretas |
|
Ejecutar un efecto inmediato que siga automáticamente las lecturas reactivas sincrónicas |
|
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
Watchers en la documentación oficial de Vue: fuentes,
watchEffecty limpieza de efectos.Vue’s watch vs watchEffect, which should I use?, de Vue Mastery: comparación centrada en el control de dependencias.
Vue computed vs watch vs watchEffect: When to Use Each One, de TODOvue: guía para elegir entre valores derivados y efectos.
AbortController.abort() en MDN: comportamiento de la cancelación en la plataforma web.
Si estás empezando con la Composition API, puedes leer primero ¿Qué es un Composable? en El Rincón de Vue.
)
Sigue leyendo
Una idea lleva a otra.
)
¿Y si Vue.js también hablara Web Components? La magia de defineCustomElement
)
Potencia tu app Vue con Firebase + VueFire
)
)