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.
Consola
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.
Consola
Solución
App.vue<script setup>
import { ref, computed } from 'vue';
import Catalog from './Catalog.vue';
const CATEGORIES = [
{ id: 4, name: 'Lácteos' },
{ id: 5, name: 'Frutas y verduras' },
{ id: 6, name: 'Bebidas' },
];
const categoryId = ref(4);
const category = computed(() => CATEGORIES.find((c) => c.id === categoryId.value));
</script>
<template>
<main>
<nav aria-label="Categorías">
<button
v-for="c in CATEGORIES"
:key="c.id"
type="button"
:aria-pressed="c.id === categoryId"
@click="categoryId = c.id"
>
{{ c.name }}
</button>
</nav>
<h1>{{ category.name }}</h1>
<Catalog :category-id="categoryId" />
</main>
</template>
Catalog.vue<script setup>
import { ref, watch } from 'vue';
import { money } from './money.js';
const props = defineProps(['categoryId']);
const products = ref([]);
const status = ref('loading');
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 });
</script>
<template>
<p v-if="status === 'loading'" role="status">Cargando…</p>
<p v-else-if="status === 'error'" role="alert">Los productos no se cargaron.</p>
<ul v-else>
<li v-for="p in products" :key="p.id">{{ p.name }}, {{ money(p.price) }}</li>
</ul>
</template>
money.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
nav { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
button { font: inherit; }
button[aria-pressed="true"] { background: #c27c2a; color: #fff; border-color: #c27c2a; }
ul { padding-left: 20px; }
[role="alert"] { color: #b3261e; }
watch(() => props.categoryId, async (categoryId) => { … }, { immediate: true }) asigna 'loading', espera la respuesta y asigna los productos y 'ready'. La comprobación detecta watch sin immediate: entonces la primera categoría se queda para siempre en «Cargando…».
La comprobación detecta también 'loading' asignado solo al arrancar el componente: al cambiar de categoría no aparece «Cargando…».
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
- El cliente pulsa «Frutas y verduras». La categoría cambia, el efecto se ejecuta y envía una petición. Esta es lenta.
- 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.
- La petición rápida responde primero: la lista muestra las bebidas.
- 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.
- Ahora con la limpieza. Cada ejecución del efecto crea su propio AbortController y pasa su signal a fetch.
- 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.
- Solo responde la petición de las bebidas. La lista y el encabezado coinciden.
- 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.
- Cada ejecución del efecto crea su propio
controller = new AbortController(). - Pasa su
controller.signalafetch:fetch(url, { signal: controller.signal }). - La limpieza llama a
controller.abort(). La petición se detiene yawait fetch(…)lanza un error llamadoAbortError. catchomite 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.
Consola
Solución
App.vue<script setup>
import { ref, computed } from 'vue';
import Catalog from './Catalog.vue';
const CATEGORIES = [
{ id: 4, name: 'Lácteos' },
{ id: 5, name: 'Frutas y verduras' },
{ id: 6, name: 'Bebidas' },
];
const categoryId = ref(4);
const category = computed(() => CATEGORIES.find((c) => c.id === categoryId.value));
</script>
<template>
<main>
<nav aria-label="Categorías">
<button
v-for="c in CATEGORIES"
:key="c.id"
type="button"
:aria-pressed="c.id === categoryId"
@click="categoryId = c.id"
>
{{ c.name }}
</button>
</nav>
<h1>{{ category.name }}</h1>
<Catalog :category-id="categoryId" />
</main>
</template>
Catalog.vue<script setup>
import { ref, watch } from 'vue';
import { money } from './money.js';
const props = defineProps(['categoryId']);
const products = ref([]);
const status = ref('loading');
watch(() => props.categoryId, async (categoryId, oldId, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
status.value = 'loading';
try {
const response = await fetch(`/api/products?category=${categoryId}`, { 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 });
</script>
<template>
<p v-if="status === 'loading'" role="status">Cargando…</p>
<p v-else-if="status === 'error'" role="alert">Los productos no se cargaron.</p>
<ul v-else>
<li v-for="p in products" :key="p.id">{{ p.name }}, {{ money(p.price) }}</li>
</ul>
</template>
money.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
nav { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
button { font: inherit; }
button[aria-pressed="true"] { background: #c27c2a; color: #fff; border-color: #c27c2a; }
ul { padding-left: 20px; }
[role="alert"] { color: #b3261e; }
El efecto crea un AbortController, pasa el signal a fetch y registra onCleanup(() => controller.abort()). catch omite AbortError.
La comprobación detecta un controlador cuyo signal no se pasa a fetch: abort() no detiene nada y las manzanas llegan igualmente. También detecta un catch que no omite AbortError: justo después de «Bebidas», la cancelación asigna 'error', y en lugar de «Cargando…» la página muestra un momento «Los productos no se cargaron.».
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.
Línea 11: todo error pasa a 'error', también la cancelación de la limpieza. Antes debe ir if (e.name === 'AbortError') return;: una petición detenida no ha fallado.
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 });abort: controller.abort() detiene la petición cuyo signal se pasó a fetch, y provoca un AbortError.
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.
<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.
Consola
Solución
App.vue<script setup>
import { ref, computed } from 'vue';
import Catalog from './Catalog.vue';
const CATEGORIES = [
{ id: 4, name: 'Lácteos' },
{ id: 5, name: 'Frutas y verduras' },
{ id: 6, name: 'Bebidas' },
];
const categoryId = ref(4);
const category = computed(() => CATEGORIES.find((c) => c.id === categoryId.value));
</script>
<template>
<main>
<nav aria-label="Categorías">
<button
v-for="c in CATEGORIES"
:key="c.id"
type="button"
:aria-pressed="c.id === categoryId"
@click="categoryId = c.id"
>
{{ c.name }}
</button>
</nav>
<h1>{{ category.name }}</h1>
<Catalog :category-id="categoryId" />
</main>
</template>
Catalog.vue<script setup>
import { ref, watch } from 'vue';
import { money } from './money.js';
const props = defineProps(['categoryId']);
const products = ref([]);
const status = ref('loading');
const attempt = ref(0);
watch([() => props.categoryId, attempt], async ([categoryId], old, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
status.value = 'loading';
try {
const response = await fetch(`/api/products?category=${categoryId}`, { 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 });
</script>
<template>
<p v-if="status === 'loading'" role="status">Cargando…</p>
<template v-else-if="status === 'error'">
<p role="alert">Los productos no se cargaron.</p>
<button type="button" @click="attempt++">Intentar de nuevo</button>
</template>
<ul v-else>
<li v-for="p in products" :key="p.id">{{ p.name }}, {{ money(p.price) }}</li>
</ul>
</template>
money.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
nav { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
button { font: inherit; }
button[aria-pressed="true"] { background: #c27c2a; color: #fff; border-color: #c27c2a; }
ul { padding-left: 20px; }
[role="alert"] { color: #b3261e; }
const attempt = ref(0), la fuente de watch pasa a ser [() => props.categoryId, attempt], la función recibe [categoryId], y con 'error' la plantilla muestra el mensaje y un botón con @click="attempt++". La comprobación detecta un botón que cambia attempt mientras attempt falta en la fuente: no arranca nada.
La comprobación detecta también un botón que solo asigna 'loading' sin una petición nueva, y código sin la comprobación de response.ok: entonces la respuesta con 500 se lee como una lista de productos.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Props: datos desde arriba · Condiciones: vacío, error, cargando · Estado · Efectos
- Continúa en: Formularios en un componente · Estado compartido · Probar componentes · Del sandbox al proyecto
- Ejercicios sobre el tema: Efectos, datos y formularios