Ir al contenido
wannacode

Datos de una API en un componente

En resumen: Los datos de una API se cargan en un componente de Vue desde watch con un estado «cargando», «error» o «listo», y un AbortController en la limpieza detiene la petición obsoleta.

Problema

Manzanas bajo «Bebidas»

El catálogo obtiene de la API los productos de la categoría. Aquí el servidor responde «Frutas y verduras» en 1,5 segundos y las demás categorías en 0,3.

Vue

Pulsa «Frutas y verduras» y enseguida «Bebidas». Aparecen las bebidas. Un segundo después, manzanas, bananas y tomates ocupan su lugar, mientras que el encabezado y el botón pulsado siguen diciendo «Bebidas». El cliente ve los productos equivocados y no sabe por qué.

La idea

Una petición es un efecto

La petición sale del componente hacia el servidor. Eso la convierte en un efecto secundario, y su sitio está en el efecto, con la categoría como dependencia: cada categoría nueva envía una petición nueva.

Los estados son los de la lección sobre fetch en la página: cargando, error, listo. Aquí son una sola pieza de estado, status, y el componente dibuja la rama que le corresponde, como en la lección sobre dibujo condicional.

watch(() => props.categoryId, async (categoryId) => {
status.value = 'loading';
try {
const response = await fetch(`/api/products?category=${categoryId}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
products.value = await response.json();
status.value = 'ready';
} catch (e) {
status.value = 'error';
}
}, { immediate: true });

La función de watch puede ser async. { immediate: true } carga la primera categoría en cuanto se crea el componente, y después cada cambio de categoryId envía una petición nueva.

status pasa a 'loading' al comienzo de cada carga, no solo la primera vez. Si no, en un cambio la lista antigua se queda bajo el encabezado nuevo hasta que llega la respuesta.

Pruébalo

Carga la categoría

Catalog tiene los estados products y status, pero nada los rellena. Carga los productos de categoryId desde /api/products?category=… y muestra «Cargando…» en cada carga. La comprobación mira los lácteos (5 productos), luego pulsa «Bebidas» y espera «Cargando…» y 4 productos.

Vue

La idea

La carrera

Cada pulsación cambia la categoría y vuelve a ejecutar el efecto. Cada ejecución envía su propia petición y espera su propia respuesta. Ninguna sabe de las demás.

Las respuestas no llegan en el orden de las peticiones. Puede que el servidor tarde más en buscar la fruta, o que la red frene una de ellas. Así que, cuando el cliente cambia rápido, hay dos peticiones en camino a la vez. Gana la que responde la última, no la que salió la última.

Esto se llama condición de carrera (race condition). No se nota mientras el servidor es rápido y regular. Se nota en una red lenta, en un móvil, en un servidor ocupado.

Míralo

Dos peticiones en camino

Las barras crecen mientras una petición viaja. Primero sin limpieza, luego con ella.

watch(() => props.categoryId, async (categoryId, oldId, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
status.value = 'loading';
try {
const url = `/api/products?category=${categoryId}`;
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
products.value = await response.json();
status.value = 'ready';
} catch (e) {
if (e.name === 'AbortError') return;
status.value = 'error';
}
}, { immediate: true });
Paso a paso, en palabras
  1. El cliente pulsa «Frutas y verduras». La categoría cambia, el efecto se ejecuta y envía una petición. Esta es lenta.
  2. Antes de que responda, el cliente pulsa «Bebidas». El efecto se ejecuta otra vez y envía una segunda petición. Ahora hay dos peticiones en camino a la vez.
  3. La petición rápida responde primero: la lista muestra las bebidas.
  4. Supongamos que falta la limpieza. La petición lenta responde la última, su ejecución también asigna los productos y bajo «Bebidas» aparece fruta.
  5. Ahora con la limpieza. Cada ejecución del efecto crea su propio AbortController y pasa su signal a fetch.
  6. La nueva categoría llama a la limpieza de la ejecución anterior: abort() detiene la petición lenta. Su fetch lanza AbortError y catch lo omite: no es un error.
  7. Solo responde la petición de las bebidas. La lista y el encabezado coinciden.
  8. La regla: una petición dentro de un efecto lleva su cancelación en la limpieza. Gana la última ejecución, porque todas las anteriores se detuvieron.

Sin limpieza, la pantalla muestra la última respuesta. Con limpieza, la petición antigua se detiene antes de responder, y la última respuesta es siempre de la última categoría.

La idea

Cancelar en la limpieza

El efecto ya tiene un sitio para «detén lo antiguo»: la limpieza. El navegador tiene una herramienta para detener una petición: AbortController.

  1. Cada ejecución del efecto crea su propio controller = new AbortController().
  2. Pasa su controller.signal a fetch: fetch(url, { signal: controller.signal }).
  3. La limpieza llama a controller.abort(). La petición se detiene y await fetch(…) lanza un error llamado AbortError.
  4. catch omite ese error: if (e.name === 'AbortError') return;. La petición no falló; la detuvo el código.
watch(() => props.categoryId, async (categoryId, oldId, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
status.value = 'loading';
try {
const response = await fetch(url, { signal: controller.signal });
…
} catch (e) {
if (e.name === 'AbortError') return;
status.value = 'error';
}
}, { immediate: true });

onCleanup va antes del primer await. Si va después, la limpieza se registra solo cuando la respuesta ya ha llegado, y ya no queda nada que detener.

La limpieza se ejecuta también cuando el componente desaparece. El cliente ha dejado el catálogo: la petición se detiene y nadie asigna productos en un componente que ya no existe.

Pruébalo

Detén la petición antigua

Haz que la petición antigua se detenga cuando el cliente cambia rápido. La comprobación pulsa «Frutas y verduras» y enseguida «Bebidas», espera «Cargando…» y las bebidas, deja pasar la respuesta lenta y se asegura de que no haya manzanas y de que no haya aparecido ningún mensaje de error.

Vue

Pruébalo

Encuentra el error

El cliente cambia rápido y ve un momento «Los productos no se cargaron.», luego llegan los productos. El servidor no devolvió ningún error. ¿Qué línea tiene la culpa?

Elige la línea con el error.

Pruébalo

Completa

¿Qué detiene la petición antigua?

watch(() => props.categoryId, (categoryId, oldId, onCleanup) => {
  const controller = new AbortController();
  onCleanup(() => controller.());
  fetch(url, { signal: controller.signal }).then(show);
}, { immediate: true });

La idea

Error e «Intentar de nuevo»

A veces el servidor responde con 500. La comprobación de response.ok lanza, catch asigna 'error' y el componente muestra el mensaje con role="alert". El cliente debería poder intentarlo de nuevo sin recargar la página.

Intentarlo de nuevo es la misma carga. El efecto ya la hace cuando cambia una dependencia. Así que «Intentar de nuevo» cambia una dependencia: un número attempt que solo cuenta los intentos.

const attempt = ref(0);
watch([() => props.categoryId, attempt], async ([categoryId], old, onCleanup) => {
// … la misma carga
}, { immediate: true });
<button type="button" @click="attempt++">Intentar de nuevo</button>

La fuente es un array: watch se ejecuta cuando cambia cualquiera de las dos. La función recibe un array con los valores.

La nueva ejecución asigna 'loading', así que el mensaje y el botón desaparecen mientras la petición viaja.

Más precisamente, para curiosos

Bibliotecas de datos. En un proyecto real, la carga rara vez se escribe a mano en cada componente. Hay bibliotecas que hacen esto y más: guardan las respuestas en una caché, no envían dos peticiones iguales, reintentan por su cuenta y actualizan los datos cuando el cliente vuelve a la pestaña. Para Vue son useFetch de VueUse, Pinia Colada y TanStack Query para Vue. No están en el sandbox y el curso no tiene lecciones sobre ellas. Por dentro hacen lo mismo que aquí: una petición en un efecto, estados, una cancelación.

Una bandera en lugar de una cancelación. En lugar de un AbortController, la limpieza puede levantar una bandera: let ignore = false; en el efecto, ignore = true en la limpieza y if (!ignore) antes de asignar los productos. La petición antigua llega igualmente al servidor, pero su respuesta se descarta. Una cancelación detiene la propia petición. Los dos caminos resuelven la carrera.

Vue y la cancelación. Un watcher de <script setup> se detiene solo cuando el componente desaparece y llama al último onCleanup. Así, una petición que aún viaja también se cancela cuando el cliente deja la página.

Ejercicio

Intentar de nuevo

Aquí la primera petición de los lácteos recibe un 500. El catálogo muestra «Los productos no se cargaron.», pero no hay forma de intentarlo de nuevo. Añade un botón «Intentar de nuevo» que vuelva a ejecutar la carga. La cancelación al cambiar rápido tiene que seguir. La comprobación espera el error, pulsa «Intentar de nuevo», espera «Cargando…» y 5 productos, y luego cambia rápido a la fruta y a las bebidas.

Vue

Test

Ponte a prueba

1. El cliente pulsa «Frutas y verduras» y luego «Bebidas». Las respuestas llegan en orden inverso. ¿Qué muestra el código sin limpieza?
2. ¿Por qué el watcher de la categoría tiene { immediate: true }?
3. ¿Por qué catch omite AbortError en lugar de mostrar «Los productos no se cargaron.»?
4. ¿Cómo vuelve a enviar la petición «Intentar de nuevo»?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·