Datos desde una API
En resumen: fetch obtiene datos de una API con async/await, comprueba response.ok y lee el JSON, y la página muestra uno de cuatro estados: cargando, error, vacío o datos.
Problema
Un escaparate vacío
La página de lácteos toma sus productos del servidor. Hoy el servidor tiene un problema y responde con el error 500. La compradora ve un encabezado y debajo no hay nada.
Consola
La consola dice products.map is not a function. El código tomó la respuesta del error, { "error": "…" }, por una lista de productos. La compradora no ve ningún error ni un «Intentar de nuevo». Y aunque el servidor funcione, la página está en blanco mientras la respuesta viene en camino.
La idea
fetch en la página
fetch(address) envía una petición y devuelve una promesa (Promise) de la respuesta. Con await la función espera la respuesta sin detener la página:
const response = await fetch('/api/products?category=4');if (!response.ok) throw new Error(`HTTP ${response.status}`);const products = await response.json();fetchsolo lanza un error cuando no hay respuesta: no hay red. Una respuesta con 404 o 500 es una respuesta, y la promesa se cumple.response.okes verdadero solo para los códigos de 200 a 299. Comprobarlo es cosa tuya.response.json()lee el cuerpo y lo convierte en un objeto. Es también una promesa, así que también llevaawait.
En el sandbox las direcciones /api/… las responde la API simulada de la tienda, sin red: GET /api/products (con ?q= y ?category=), /api/products/3, /api/categories, /api/orders y POST /api/orders. Una lección puede hacerla lenta, indicarle que responda con 500 o hacer que devuelva una lista vacía. Así cada estado es real y siempre el mismo.
La idea
Cuatro estados
Una petición tarda y tiene un resultado. La pantalla tiene que decir qué pasa en cada momento:
- cargando: la petición salió, todavía no hay respuesta;
- datos: llegó una lista de productos;
- vacío: llegó una lista, pero está vacía. No es un error;
- error: no hay respuesta, o la respuesta no es
ok.
Cada estado es un elemento en el HTML. El código muestra y oculta los elementos con la propiedad hidden:
<p id="loading" role="status">Cargando…</p><p id="error" role="alert" hidden></p><p id="empty" hidden>No hay productos en esta categoría.</p><ul id="products"></ul>«Cargando…» se ve desde el principio, antes incluso de que se ejecute JavaScript. role="status" hace que un lector de pantalla lo lea, y role="alert" lee el error en cuanto aparece.
Míralo
Observa una petición
async function load() { try { const response = await fetch('/api/products?category=4'); if (!response.ok) throw new Error(`HTTP ${response.status}`); const products = await response.json(); empty.hidden = products.length > 0; list.replaceChildren(...products.map(productItem)); } catch { error.textContent = 'Los productos no se cargaron.'; error.hidden = false; } finally { loading.hidden = true; }}Paso a paso, en palabras
- load() envía la petición. Todavía no hay respuesta: la pantalla muestra «Cargando…» desde el HTML.
- await espera, pero la página no se congela: la compradora puede hacer scroll y el spinner gira. Con una red lenta esto son segundos.
- 200 y un array: ok es true, json() da los productos, se dibujan las líneas y finally oculta «Cargando…».
- La misma petición, respuesta 500. fetch no lanza nada: recibió una respuesta. Lanza nuestra comprobación de ok, y catch muestra el error.
- 200 y []: esto no es un error. El array está vacío, así que la página dice «no hay productos» en lugar de dejar un espacio en blanco.
- Cuatro estados, cada uno visible: cargando, datos, error, vacío. finally oculta «Cargando…» en cada salida de try.
El mismo código, tres respuestas distintas. Con 500, fetch no lanza nada: lanza la comprobación de ok, y catch muestra el error. finally oculta «Cargando…» en los tres casos.
Míralo
La API lenta
Aquí la API responde al cabo de 1,2 segundos. Pulsa «Ejecutar» y observa «Cargando…».
Consola
Todo el código de carga:
async function load() { try { const response = await fetch('/api/products?category=4'); if (!response.ok) throw new Error(`HTTP ${response.status}`); const products = await response.json(); empty.hidden = products.length > 0; list.replaceChildren(...products.map(productItem)); } catch { error.textContent = 'Los productos no se cargaron.'; error.hidden = false; } finally { loading.hidden = true; }}
load();Pruébalo
Error
Aquí la primera petición recibe un 500 y la segunda pasa. Cuando haya un error, haz que la página muestre «Los productos no se cargaron.» en #error y el botón «Intentar de nuevo», que «Cargando…» desaparezca y que no se muestre «no hay productos». El botón ya vuelve a cargar: después de pulsarlo, los productos tienen que estar en la página.
Consola
Solución
index.html<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>Lácteos</title>
<link rel="stylesheet" href="style.css">
<script src="script.js" defer></script>
</head>
<body>
<h1>Lácteos</h1>
<p id="loading" role="status">Cargando…</p>
<p id="error" role="alert" hidden></p>
<button id="retry" hidden>Intentar de nuevo</button>
<p id="empty" hidden>No hay productos en esta categoría.</p>
<ul id="products"></ul>
</body>
</html>
style.cssbody { font-family: system-ui, sans-serif; margin: 0 16px; }
li { margin: 4px 0; }
#error { color: #b3261e; }
label { display: block; margin-top: 8px; }
.error { color: #b3261e; margin: 2px 0 0; min-height: 1.2em; }
button { font: inherit; margin-top: 12px; }
script.jsconst loading = document.querySelector('#loading');
const error = document.querySelector('#error');
const empty = document.querySelector('#empty');
const list = document.querySelector('#products');
const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
function productItem(product) {
const li = document.createElement('li');
li.textContent = `${product.name}, ${money(product.price)}`;
return li;
}
const retry = document.querySelector('#retry');
retry.addEventListener('click', () => {
retry.hidden = true;
error.hidden = true;
loading.hidden = false;
load();
});
async function load() {
try {
const response = await fetch('/api/products?category=4');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const products = await response.json();
empty.hidden = products.length > 0;
list.replaceChildren(...products.map(productItem));
} catch {
error.textContent = 'Los productos no se cargaron.';
error.hidden = false;
retry.hidden = false;
} finally {
loading.hidden = true;
}
}
load();
La comprobación de response.ok lanza un error con 500, catch escribe el mensaje y muestra #error y el botón, y finally oculta «Cargando…». Sin la comprobación, el código lee { "error": "…" } como productos: products.length es undefined, y undefined > 0 es falso, así que se muestra «no hay productos», y eso es falso. La comprobación pulsa «Intentar de nuevo», porque una página que siempre muestra el error pasaría también la primera mitad.
Pruébalo
Vacío
Aquí la API devuelve una lista vacía. Muestra «No hay productos en esta categoría.» cuando haya cero productos, y ocúltalo cuando haya alguno.
Consola
Solución
index.html<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>Lácteos</title>
<link rel="stylesheet" href="style.css">
<script src="script.js" defer></script>
</head>
<body>
<h1>Lácteos</h1>
<p id="loading" role="status">Cargando…</p>
<p id="error" role="alert" hidden></p>
<p id="empty" hidden>No hay productos en esta categoría.</p>
<ul id="products"></ul>
</body>
</html>
style.cssbody { font-family: system-ui, sans-serif; margin: 0 16px; }
li { margin: 4px 0; }
#error { color: #b3261e; }
label { display: block; margin-top: 8px; }
.error { color: #b3261e; margin: 2px 0 0; min-height: 1.2em; }
button { font: inherit; margin-top: 12px; }
script.jsconst loading = document.querySelector('#loading');
const error = document.querySelector('#error');
const empty = document.querySelector('#empty');
const list = document.querySelector('#products');
const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
function productItem(product) {
const li = document.createElement('li');
li.textContent = `${product.name}, ${money(product.price)}`;
return li;
}
async function load() {
try {
const response = await fetch('/api/products?category=4');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const products = await response.json();
empty.hidden = products.length > 0;
list.replaceChildren(...products.map(productItem));
} catch {
error.textContent = 'Los productos no se cargaron.';
error.hidden = false;
} finally {
loading.hidden = true;
}
}
load();
empty.hidden = products.length > 0;: el mensaje se oculta justo cuando hay al menos un producto. if (!products) no funciona: un array vacío es «verdadero», y !products es falso.
Pruébalo
Encuentra el error
El servidor responde con 200 y productos, y la página muestra un error.
Elige la línea con el error.
En la línea 5 falta await. response.json() devuelve una promesa, no un array. Una promesa no tiene map, así que la línea 6 lanza un error y catch muestra un error, aunque el servidor sí respondió.
La idea
Enviar: el botón y la espera
El pedido sale con POST. Los datos van en el cuerpo como JSON, y la cabecera Content-Type le dice al servidor qué recibe:
const response = await fetch('/api/orders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...data, items }),});data es el objeto con los campos del formulario: Object.fromEntries(new FormData(form)) convierte los pares nombre=valor en un objeto { name: …, email: …, address: … }.
Mientras la respuesta viene en camino, el botón «Pedir» sigue activado. Una compradora impaciente lo pulsa dos veces:
Consola
Dos peticiones, dos pedidos: 61 y 62. La página muestra el último, y nadie se da cuenta de que el primero también se aceptó.
Pruébalo
Un clic, un pedido
Desactiva el botón «Pedir» antes de que salga la petición y vuelve a activarlo cuando llegue la respuesta, aunque sea un error. La comprobación pulsa dos veces seguidas y espera un solo pedido: el 61.
Consola
Solución
index.html<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>Pedido rápido</title>
<link rel="stylesheet" href="style.css">
<script src="script.js" defer></script>
</head>
<body>
<h1>Pedido rápido</h1>
<p>Pan blanco × 2, Leche fresca × 1</p>
<form id="order">
<label>Nombre <input name="name" value="Lucía Romero"></label>
<label>Correo electrónico <input name="email" value="lucia@example.com"></label>
<label>Dirección <input name="address" value="Calle Mayor 7"></label>
<button>Pedir</button>
</form>
<p id="result" role="status"></p>
</body>
</html>
style.cssbody { font-family: system-ui, sans-serif; margin: 0 16px; }
li { margin: 4px 0; }
#error { color: #b3261e; }
label { display: block; margin-top: 8px; }
.error { color: #b3261e; margin: 2px 0 0; min-height: 1.2em; }
button { font: inherit; margin-top: 12px; }
script.jsconst items = [{ productId: 1, qty: 2 }, { productId: 5, qty: 1 }];
const form = document.querySelector('#order');
const button = form.querySelector('button');
const result = document.querySelector('#result');
form.addEventListener('submit', async (event) => {
event.preventDefault();
button.disabled = true;
try {
const data = Object.fromEntries(new FormData(form));
const response = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...data, items }),
});
const order = await response.json();
result.textContent = `Pedido ${order.id} aceptado.`;
} finally {
button.disabled = false;
}
});
button.disabled = true es lo primero después de preventDefault(), antes de await. Un botón desactivado no recibe clics y no envía el formulario. finally { button.disabled = false; } lo activa de nuevo después de un éxito y después de un error. Si el botón se desactiva después de await fetch(…), el segundo clic ya pasó.
Más precisamente, para curiosos
Un error de red. Sin red, fetch lanza TypeError: Failed to fetch. catch atrapa eso y tu propio throw en !response.ok: para la compradora, los dos significan «no cargó».
El cuerpo de un error. Un servidor suele explicar el error en el cuerpo, como JSON. Con 400, la API simulada devuelve { "errors": { "email": "…" } }: un error por campo. Por eso el ejercicio lee el cuerpo también cuando hay un error.
Intentar de nuevo. Un botón «Intentar de nuevo» junto al error solo vuelve a llamar a load(). Antes, «Cargando…» tiene que volver a mostrarse y el error tiene que ocultarse.
Una carrera entre peticiones. Si la compradora cambia de categoría dos veces seguidas, las dos respuestas pueden llegar en orden inverso y la página muestra la categoría vieja. Cómo detener una petición vieja (AbortController) está en el módulo sobre efectos.
Comprobaciones en el navegador y en el servidor. required y type="email" detienen el formulario antes de que salga. El servidor vuelve a comprobar, porque una petición puede llegar sin nuestra página. En el ejercicio los campos no tienen comprobaciones integradas, para que veas lo que responde el servidor.
Ejercicio
El pedido
Envía el pedido: POST /api/orders con name, email y address del formulario e items del array. Mientras se envía, el botón está desactivado. Con 400, pon cada mensaje de errors en el párrafo bajo su campo (#email-error y así sucesivamente) y borra los mensajes viejos en cada envío nuevo. Si sale bien, escribe «Pedido 61 aceptado. Total: 4,00 €» en #result (el número y el total vienen de la respuesta).
Consola
Solución
index.html<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>Pedido</title>
<link rel="stylesheet" href="style.css">
<script src="script.js" defer></script>
</head>
<body>
<h1>Pedido</h1>
<p>Pan blanco × 2, Leche fresca × 1</p>
<p class="error" id="items-error"></p>
<form id="order">
<label>Nombre <input name="name"></label>
<p class="error" id="name-error"></p>
<label>Correo electrónico <input name="email"></label>
<p class="error" id="email-error"></p>
<label>Dirección <input name="address"></label>
<p class="error" id="address-error"></p>
<button>Pedir</button>
</form>
<p id="result" role="status"></p>
</body>
</html>
style.cssbody { font-family: system-ui, sans-serif; margin: 0 16px; }
li { margin: 4px 0; }
#error { color: #b3261e; }
label { display: block; margin-top: 8px; }
.error { color: #b3261e; margin: 2px 0 0; min-height: 1.2em; }
button { font: inherit; margin-top: 12px; }
script.jsconst items = [{ productId: 1, qty: 2 }, { productId: 5, qty: 1 }];
const form = document.querySelector('#order');
const button = form.querySelector('button');
const result = document.querySelector('#result');
const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
form.addEventListener('submit', async (event) => {
event.preventDefault();
button.disabled = true;
result.textContent = '';
for (const p of document.querySelectorAll('.error')) p.textContent = '';
try {
const data = Object.fromEntries(new FormData(form));
const response = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...data, items }),
});
const answer = await response.json();
if (response.status === 400) {
for (const [name, message] of Object.entries(answer.errors)) {
document.querySelector(`#${name}-error`).textContent = message;
}
} else if (response.ok) {
result.textContent = `Pedido ${answer.id} aceptado. Total: ${money(answer.total)}`;
} else {
result.textContent = 'El pedido no se envió. Inténtalo de nuevo.';
}
} finally {
button.disabled = false;
}
});
El servidor devuelve 400 y { errors: { email: …, address: … } }. El bucle sobre Object.entries(answer.errors) encuentra el párrafo #${name}-error y escribe el mensaje. Si los mensajes viejos no se borran, tras la corrección «El correo electrónico no es válido» se queda bajo un campo correcto. Con 201 la respuesta es { id, status, total }: el número y el total vienen del servidor, no de la página.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Accesibilidad · El DOM: la página desde JavaScript · Eventos · Una lista a partir de datos
- Continúa en: Condiciones: vacío, error, cargando · Datos de una API en un componente · Formularios en un componente · El escaparate de la tienda
- Ejercicios sobre el tema: JavaScript en la página