Has construido un Team Builder entero en React, con routing, estado global, formularios y patrones. Pero una mentira piadosa ha sostenido todo el nivel: los datos nunca salieron de tu ordenador. El cargarHeroes() que usabas era un array fijo detrás de un setTimeout —tardaba “de mentira” y no fallaba nunca—. Este capítulo, corto, cambia esa promesa simulada por un fetch de verdad. Y lo bueno: corre en este mismo playground, porque vamos a pedir a una API pública con CORS abierto.
El fetch real, ahora dentro de React#
En “JSON y fetch” (Nivel 3) ya hiciste un fetch real contra una API pública, pero en la consola y con datos sueltos. Aquí lo llevas a donde vive en una app: dentro de un componente, con useEffect para lanzarlo y useState para guardar lo que llega y el estado de carga.
// La URL de la API. En el playground va aquí; en un proyecto real iría en .env (Nivel 4).
const API_URL = "https://jsonplaceholder.typicode.com/users";
function useRoster() {
const [heroes, setHeroes] = useState<Heroe[]>([]);
const [cargando, setCargando] = useState(true);
useEffect(() => {
// fetch sale a la red de verdad; resuelve con un Response, no con los datos.
fetch(API_URL)
// res.json() lee y parsea el cuerpo (otra promesa).
.then((res) => res.json())
.then((datos) => {
// La API devuelve SU forma (name); la adaptamos a la nuestra (nombre).
setHeroes(datos.map((u) => ({ id: u.id, nombre: u.name })));
setCargando(false);
});
}, []);
return { heroes, cargando };
}Ese último paso es nuevo e importante: una API pública no te da héroes de Overwatch con tu forma exacta. Te da lo que tiene, y tú lo adaptas a tu dominio. Y como lo que llega de fuera no es de fiar, en cuanto puedas lo validas con Zod (lo viste en el Nivel 5): si la forma no encaja, parse() falla ahí mismo, con un mensaje claro, en vez de dejar que un dato corrupto se cuele y reviente lejos.
Pruébalo#
Esto que ves cargando sale de una API pública de verdad, ahora mismo, desde el playground. Recarga la demo y verás el “Cargando…” un instante antes de que lleguen los datos —eso es la red, que ya no es instantánea—.
La red falla, y la UI tiene que contarlo#
Aquí está el “¿y qué?” que el playground escondía. Una red real falla: estás sin conexión, el servidor devuelve un 500, la petición caduca. Y hay una trampa que ya conoces del Nivel 3: cuando la API responde con un 404 o un 500, fetch no rechaza la promesa —eso es una respuesta HTTP válida—, así que tu código sigue tan tranquilo con un Response de error en la mano. Hay que comprobarlo:
const res = await fetch(API_URL);
// res.ok es true solo si el estado es 2xx. Sin esta línea, un 500 se cuela como un éxito.
if (!res.ok) throw new Error("La API respondió " + res.status);En el playground, ese estado de error era decorativo: la promesa simulada no fallaba nunca. Con una red real, es la mitad de tu UI. Un try/catch que recoge el fallo y lo enseña, y un botón de “reintentar”, dejan de ser un lujo: sin ellos, un fallo deja al usuario mirando un “Cargando…” eterno, sin saber qué pasó ni cómo salir.
CORS: el muro del navegador#
Y hay un fallo nuevo que el playground no te enseñó, porque la demo de arriba lo esquiva por suerte. Cambia la URL por la de otra API cualquiera y es muy probable que, en lugar de datos, te salte un error rojo de CORS en la consola.
CORS es una protección del navegador, no de la API. La demo de arriba funciona porque jsonplaceholder responde con una cabecera (Access-Control-Allow-Origin: *) que permite a cualquier origen leer su respuesta. Una API que no la mande, no. Y lo incómodo: tu petición sí llega al servidor —por eso un curl desde la terminal (que no aplica CORS) funcionaría—, pero el navegador te esconde la respuesta. El “¿y qué?”: no se arregla desde el frontend. No hay header mágico que añadir a tu fetch. O la API permite tu origen, o tienes que enrutar la petición por un backend propio (que llama a la API sin un navegador de por medio). Es la frontera real entre lo que el frontend puede hacer solo y lo que necesita un servidor detrás.
Comprueba lo que sabes#
Pregunta 1 de 3
Tu fetch a la API recibe un 500, pero tu código sigue adelante y revienta al usar los datos. ¿Por qué?
Tu turno#
El starter trae el roster simulado de todo el nivel. Cámbialo por un fetch real contra la API pública (la URL ya está puesta) y sube de tier: primero que los datos sean reales, luego controla res.ok y valida con Zod, y al final añade reintento. Compara cada tier con su solución.
Ejercicio · en esta página
Tu primer fetch real
El starter trae el roster SIMULADO que has usado todo el nivel. Cámbialo por un fetch real contra la API pública (la URL ya está en el fichero). Adapta la forma de la API a la tuya, gestiona la carga y el error, y sube de tier según la robustez.
Paso 1: Datos reales en pantalla
- El roster ya no es un array fijo: viene de un fetch real con useEffect + useState
- La forma de la API (name) se adapta a la tuya (nombre)
- Mientras la red trabaja se muestra un estado de carga
Paso 2: Errores y validación
- Se comprueba res.ok y se lanza un error si la API responde mal (fetch no rechaza ante un 404/500)
- La respuesta se valida con Zod antes de entrar en la app
- Un fallo (red, HTTP o validación) muestra un estado de error, no un spinner eterno
Paso 3: Reintentar y móvil
- Cuando hay error, la UI ofrece un botón de reintentar que vuelve a lanzar la carga
- La función de carga está en un useCallback y es la dependencia del efecto
- Aguanta a 375px sin romperse
Ver soluciones
import { useEffect, useState } from "react";
// API pública de pruebas (la misma de "JSON y fetch", Nivel 3); CORS abierto.
// En un proyecto real, esta URL iría en una variable de entorno (.env, Nivel 4).
const API_URL = "https://jsonplaceholder.typicode.com/users";
interface Heroe {
id: number;
nombre: string;
}
export default function App() {
const [heroes, setHeroes] = useState<Heroe[]>([]);
const [cargando, setCargando] = useState(true);
useEffect(() => {
// fetch REAL: sale a la red de verdad. Devuelve una promesa que resuelve con un Response.
fetch(API_URL)
// res.json() lee y parsea el cuerpo de la respuesta (otra promesa).
.then((res) => res.json())
.then((datos) => {
// La API devuelve SU forma (name, email...); la adaptamos a la nuestra (id, nombre).
const adaptados = datos.map((u: { id: number; name: string }) => ({
id: u.id,
nombre: u.name,
}));
setHeroes(adaptados);
// Ya hay datos: quitamos el estado de carga.
setCargando(false);
});
}, []);
// Mientras la red trabaja, mostramos un aviso de carga (ya no es instantáneo como el array fijo).
if (cargando) return <p className="estado">Cargando héroes de la API…</p>;
return (
<section>
<h1 className="titulo">Roster ({heroes.length})</h1>
<ul className="lista">
{heroes.map((h) => (
<li key={h.id} className="tarjeta">{h.nombre}</li>
))}
</ul>
</section>
);
} Por qué este nivel
- El cambio clave: el roster ya no es un array fijo, sale de un fetch real dentro de un useEffect. Ya hiciste un fetch en JSON y fetch (Nivel 3), pero en la consola; aquí vive donde le toca —en un componente—, y su resultado pinta la UI.
- Dos cosas que el playground escondía aparecen de inmediato: la respuesta tarda (de ahí el estado de carga) y llega con la forma de la API (name), que adaptas a la tuya (nombre).
import { useEffect, useState } from "react";
import { z } from "zod";
const API_URL = "https://jsonplaceholder.typicode.com/users";
// Validamos la forma que llega de la API: lo que viene de fuera no es de fiar
// (lo viste en "Tipar fetch y validar con Zod", Nivel 5).
const UsuarioApiSchema = z.object({
id: z.number(),
name: z.string(),
});
const RespuestaSchema = z.array(UsuarioApiSchema);
interface Heroe {
id: number;
nombre: string;
}
export default function App() {
const [heroes, setHeroes] = useState<Heroe[]>([]);
const [cargando, setCargando] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
async function cargar() {
try {
const res = await fetch(API_URL);
// fetch NO rechaza ante un 404/500: hay que comprobar res.ok a mano.
if (!res.ok) throw new Error("La API respondió " + res.status);
const datos = await res.json();
// parse() lanza si la forma no encaja: un error claro en vez de datos corruptos.
const validados = RespuestaSchema.parse(datos);
setHeroes(validados.map((u) => ({ id: u.id, nombre: u.name })));
} catch (e) {
// Cualquier fallo —red, HTTP o validación— acaba aquí y se le cuenta al usuario.
setError("No se pudieron cargar los héroes.");
} finally {
setCargando(false);
}
}
cargar();
}, []);
if (cargando) return <p className="estado">Cargando héroes de la API…</p>;
// Sin este estado, un fallo de red dejaría al usuario mirando un spinner para siempre.
if (error) return <p className="error">{error}</p>;
return (
<section>
<h1 className="titulo">Roster ({heroes.length})</h1>
<ul className="lista">
{heroes.map((h) => (
<li key={h.id} className="tarjeta">{h.nombre}</li>
))}
</ul>
</section>
);
} Por qué es mejor que el anterior
- Lo naive del 'ok' tiene dos agujeros que una red real destapa. Primero, fetch NO rechaza ante un 404 o un 500 —solo ante un fallo de red—, así que sin comprobar res.ok seguirías con una respuesta de error en la mano. Segundo, lo que llega de fuera no es de fiar: Zod valida la forma antes de dejarla entrar.
- Y el estado de error en la UI: un try/catch que recoge cualquier fallo y lo cuenta, en vez de dejar al usuario mirando un spinner para siempre.
import { useCallback, useEffect, useState } from "react";
import { z } from "zod";
const API_URL = "https://jsonplaceholder.typicode.com/users";
const UsuarioApiSchema = z.object({
id: z.number(),
name: z.string(),
});
const RespuestaSchema = z.array(UsuarioApiSchema);
interface Heroe {
id: number;
nombre: string;
}
export default function App() {
const [heroes, setHeroes] = useState<Heroe[]>([]);
const [cargando, setCargando] = useState(true);
const [error, setError] = useState("");
// useCallback: la MISMA función de carga sirve para el efecto inicial y para reintentar,
// con identidad estable (no nace una nueva en cada render).
const cargar = useCallback(async () => {
setCargando(true);
setError("");
try {
const res = await fetch(API_URL);
// fetch solo rechaza ante un fallo de red; un 404/500 hay que detectarlo con res.ok.
if (!res.ok) throw new Error("La API respondió " + res.status);
const datos = await res.json();
// Validamos la forma antes de usarla: la API que no controlas puede cambiar.
const validados = RespuestaSchema.parse(datos);
setHeroes(validados.map((u) => ({ id: u.id, nombre: u.name })));
} catch (e) {
setError("No se pudieron cargar los héroes.");
} finally {
setCargando(false);
}
}, []);
// El efecto dispara la primera carga; cargar es dependencia (está envuelta en useCallback).
useEffect(() => {
cargar();
}, [cargar]);
if (cargando) return <p className="estado">Cargando héroes de la API…</p>;
if (error) {
return (
<section>
<p className="error">{error}</p>
{/* Una salida real cuando la red falla: reintentar, no un spinner eterno. */}
<button className="boton" onClick={cargar}>Reintentar</button>
</section>
);
}
return (
<section>
<h1 className="titulo">Roster ({heroes.length})</h1>
<ul className="lista">
{heroes.map((h) => (
<li key={h.id} className="tarjeta">{h.nombre}</li>
))}
</ul>
</section>
);
} Por qué es mejor que el anterior
- La función de carga se extrae a un useCallback para reutilizarla: el efecto la llama al montar, y el botón de 'Reintentar' la vuelve a llamar. Una red real falla a veces de forma transitoria, así que dar una salida (reintentar) es la diferencia entre una UI usable y un callejón sin salida.
- useCallback le da identidad estable, así que puede ser dependencia del useEffect sin disparar un bucle (la lista de dependencias que tanto cuidaste en los capítulos de hooks). Y aguanta 375px: salir al mundo real incluye el móvil.