Módulo 4: Server State Is Different
Loading y error, repetidos en cada componente
Descripción
Este es el quinto y último mal del fetching manual, y sale directo de la propiedad "es asíncrono y puede fallar" (lección 3). Como pedir datos al backend lleva tiempo y puede romperse, cada componente que hace fetch tiene que modelar la máquina de tres caras —loading, data, error— con su propio repertorio: tres useState (o uno con un objeto), un useEffect, un .then para el éxito y un .catch para el error, y en el render, tres ramas (if (isLoading)…, if (error)…, y por fin los datos). Ese bloque se repite en cada componente que pide algo. Es boilerplate: código idéntico, mecánico, copiado una y otra vez, que además es fácil de escribir mal (olvidar el error, dejar un spinner infinito, leer data.name cuando data aún es null).
Pero hay un mal peor que la repetición, y es el que esta lección mide: como cada componente tiene su propia máquina para el mismo dato, esas máquinas pueden estar en estados distintos al mismo tiempo. Tres componentes que muestran los mismos productos pueden estar, en un instante, uno cargando, otro en error y otro con datos —para exactamente la misma verdad remota—. La UI queda incoherente: un spinner aquí, un "reintentar" allá, y datos en un tercer lugar, todo del mismo /products. No es un bug de lógica; es la consecuencia de tener tres copias independientes de un estado que debería ser uno. La cura, otra vez, es una caché por queryKey: un estado { isLoading, isError, data } por dato, leído por todos, siempre coherente.
Conexión con el módulo. Con esta lección se completan los cinco males del fetching manual: sin caché, sin dedup (L4), race, sin revalidación (L5), y loading/error repetido (L6). La lección 7 los junta y muestra la capa única que los resuelve todos. Aquí verás el mal más "invisible" —no es que la app falle, es que cada componente carga su propia maquinaria y a veces se desincronizan—, y por qué la solución no es "escribir mejor el boilerplate" sino no escribirlo: que la máquina loading/error viva una vez, en la capa, por queryKey. Eso es exactamente lo que React Query entrega como isLoading/isError/data (M5).
Una analogía: tres cajeros con su propia nota sobre la bóveda
Imagina un banco con tres ventanillas, cada una con su cajero, y una sola bóveda al fondo (la verdad remota). Cada cajero, para saber si la bóveda está abierta y con cuánto dinero, manda a su propio asistente a revisarla y anota el resultado en su libreta. Como los tres asistentes salen en momentos distintos y la bóveda tarda en responder, en un instante cualquiera las tres libretas pueden decir cosas diferentes: la del cajero 1 dice "bóveda abierta, $10,000" (su asistente ya volvió), la del cajero 2 dice "esperando al asistente" (aún no vuelve), y la del cajero 3 dice "error: el asistente no encontró la bóveda" (se equivocó de pasillo). La misma bóveda, tres estados distintos en las tres ventanillas, al mismo tiempo. Un cliente que pase por las tres ve tres respuestas contradictorias sobre el mismo dinero.
Y ni siquiera es solo la incoherencia: es que los tres cajeros montaron el mismo aparato —un asistente, una libreta, una rutina de "si vuelve con dato lo anoto, si no vuelve espero, si se pierde marco error"—. Tres veces la misma maquinaria para revisar una bóveda. El diseño sensato es obvio: un tablero central en la pared, que un encargado mantiene —"bóveda: abierta / cerrada / revisando"—, y los tres cajeros lo leen. Una máquina, un estado, tres lectores coherentes. Ese tablero es la caché por queryKey: isLoading, isError, data calculados una vez para "la bóveda" (el dato) y leídos por todos los que lo muestran. Nadie monta su propio asistente; todos miran el mismo tablero.
Ejemplo trabajado: tres estados distintos para el mismo dato
Modelamos en Node tres componentes que muestran los mismos productos, cada uno con su máquina de estados, y vemos cómo, a mitad de camino, quedan en estados distintos. Luego, la versión con una máquina compartida. Primero, cómo se ve el boilerplate en React —el bloque que se repite en cada componente—:
// El boilerplate que CADA componente re-implementa para un fetch:
function ProductList() {
const [data, setData] = useState(null);
const [isLoading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch('/products')
.then((r) => r.json())
.then((d) => { setData(d); setLoading(false); })
.catch((e) => { setError(e.message); setLoading(false); });
}, []);
if (isLoading) return <Spinner />; // rama 1
if (error) return <Error msg={error} />; // rama 2
return <ul>{data.map(/* ... */)}</ul>; // rama 3
}
// FeaturedGrid y SearchResults tienen EXACTAMENTE este mismo bloque -> 3 maquinas.
Tres useState, un useEffect, tres ramas en el render —copiado en cada componente que pide algo—. Ejecutemos qué pasa cuando las tres máquinas corren por su cuenta:
// Por que useState+useEffect esta MAL a escala (parte 3): la maquina loading/data/error
// se REPITE en cada componente, y las copias independientes pueden estar en estados DISTINTOS
// para el MISMO dato a la vez (UI inconsistente).
console.log('=== useState+useEffect: el estado loading/error repetido en cada componente ===\n');
// La maquina de estados que TODO fetch necesita (loading -> success | error).
// Con useState+useEffect, cada componente la re-implementa por su cuenta.
function makeManualFetcher(name) {
return { name, status: 'loading', data: null, error: null }; // cada uno arranca en loading
}
// Tres componentes piden el MISMO dato (/products), cada uno con SU maquina:
const productList = makeManualFetcher('ProductList');
const featured = makeManualFetcher('FeaturedGrid');
const searchRes = makeManualFetcher('SearchResults');
const all = [productList, featured, searchRes];
function paint(label) {
console.log(' ' + label);
all.forEach((c) => {
const shown = c.status === 'loading' ? 'Cargando...'
: c.status === 'error' ? 'Error: ' + c.error
: c.data;
console.log(' ' + c.name.padEnd(15) + ' status=' + c.status.padEnd(8) + ' muestra: ' + shown);
});
}
console.log('Tres componentes, cada uno con SU maquina de estados para el MISMO /products:\n');
paint('t0 — todos montan (todos en loading):');
// Las respuestas llegan en momentos distintos, y una FALLA.
productList.status = 'success'; productList.data = 'Mouse, Keyboard';
console.log();
paint('t1 — a ProductList ya le llego; a los otros no:');
featured.status = 'error'; featured.error = 'NetworkError';
console.log();
paint('t2 — a FeaturedGrid le FALLO; SearchResults sigue cargando:');
console.log('\n >> mismo /products, tres estados a la vez: uno con datos, uno en error, uno cargando.');
console.log(' >> y la maquina loading/data/error esta escrita 3 veces (una por componente).');
// ---------- CON una capa: UNA maquina por queryKey, leida por todos ----------
console.log('\nCON una capa de cache (un estado por queryKey "products", leido por los 3):');
const query = { status: 'loading', data: null, error: null }; // UNA sola maquina
function useProducts() { return query; } // los 3 componentes leen el MISMO objeto
query.status = 'success'; query.data = 'Mouse, Keyboard'; // una respuesta -> todos coherentes
['ProductList', 'FeaturedGrid', 'SearchResults'].forEach((name) => {
const q = useProducts();
console.log(' ' + name.padEnd(15) + ' isLoading=' + (q.status === 'loading') +
' isError=' + (q.status === 'error') + ' data: ' + q.data);
});
console.log('\n >> un estado, tres lectores: siempre coherentes, y el boilerplate se escribe UNA vez.');
console.log(' isLoading / isError / data los da la capa hechos (React Query, M5).');
Qué esperar. Al correr el archivo con Node, la salida es exactamente esta:
=== useState+useEffect: el estado loading/error repetido en cada componente ===
Tres componentes, cada uno con SU maquina de estados para el MISMO /products:
t0 — todos montan (todos en loading):
ProductList status=loading muestra: Cargando...
FeaturedGrid status=loading muestra: Cargando...
SearchResults status=loading muestra: Cargando...
t1 — a ProductList ya le llego; a los otros no:
ProductList status=success muestra: Mouse, Keyboard
FeaturedGrid status=loading muestra: Cargando...
SearchResults status=loading muestra: Cargando...
t2 — a FeaturedGrid le FALLO; SearchResults sigue cargando:
ProductList status=success muestra: Mouse, Keyboard
FeaturedGrid status=error muestra: Error: NetworkError
SearchResults status=loading muestra: Cargando...
>> mismo /products, tres estados a la vez: uno con datos, uno en error, uno cargando.
>> y la maquina loading/data/error esta escrita 3 veces (una por componente).
CON una capa de cache (un estado por queryKey "products", leido por los 3):
ProductList isLoading=false isError=false data: Mouse, Keyboard
FeaturedGrid isLoading=false isError=false data: Mouse, Keyboard
SearchResults isLoading=false isError=false data: Mouse, Keyboard
>> un estado, tres lectores: siempre coherentes, y el boilerplate se escribe UNA vez.
isLoading / isError / data los da la capa hechos (React Query, M5).
Lee la corrida como el problema y la cura.
El problema — tres máquinas que se desincronizan. En t0 los tres montan y están en loading (los tres asistentes salieron a revisar la bóveda). En t1, a ProductList ya le llegó su respuesta (success, muestra los productos), pero a los otros dos no (siguen en loading). En t2, a FeaturedGrid le falló la petición (error: NetworkError), mientras SearchResults todavía carga y ProductList ya tiene datos. Mira ese instante: el mismo /products se está mostrando de tres formas a la vez —datos, error, cargando—. Un usuario que mire la página ve un spinner en una parte, un "reintentar" en otra, y el catálogo en una tercera, todo del mismo dato. Es incoherente, y no por un error de código: es la consecuencia natural de que cada componente tenga su propia máquina para un dato que es uno solo.
La cura — una máquina, tres lectores. Con una caché por queryKey, hay un estado para 'products', y los tres componentes lo leen. Cuando la respuesta llega, los tres pasan a isLoading=false, isError=false, data: Mouse, Keyboard —a la vez, porque leen el mismo objeto—. Es imposible que uno cargue mientras otro muestra datos, porque no hay tres estados: hay uno. Y el boilerplate —la máquina loading/error, el .then, el .catch— se escribió una vez (en la capa), no tres. Los componentes solo leen isLoading, isError, data y deciden qué pintar. Ese es el tablero central en la pared: un encargado lo mantiene, todos lo leen, nadie monta su propio asistente.
La moraleja recoge dos ganancias en una: coherencia (un estado compartido no se desincroniza) y menos código (la máquina se escribe una vez). Las dos vienen de lo mismo —una copia por dato en vez de una por componente—, que es la tesis del módulo 1 aplicada, ahora, al estado de carga y no solo a los datos.
No es "escribir mejor el boilerplate", es no escribirlo
La tentación al ver el boilerplate repetido es extraer un custom hook —useFetch(url)— que encapsule los tres useState y el useEffect, y usarlo en cada componente. Es un buen instinto (menos código repetido), pero no resuelve el mal principal:
useFetch(url) como custom hook
│
├─ SÍ: deja de copiar los 3 useState + useEffect en cada componente
│
└─ NO: cada COMPONENTE que llama useFetch('/products') sigue teniendo
su PROPIA instancia de estado -> tres maquinas, se desincronizan igual
(y sigue sin cache, sin dedup, sin revalidacion, sin guard de race)
Un useFetch reduce la repetición de código, pero cada componente que lo llama crea su propio estado —tres llamadas a useFetch('/products') son tres máquinas independientes, exactamente como antes—. La incoherencia de t2 seguiría pasando. Y useFetch, por sí solo, tampoco trae caché, dedup, revalidación ni descarte de respuestas viejas: solo empaqueta el boilerplate, no lo comparte. La diferencia clave de una capa de estado del servidor es que el estado vive fuera de los componentes, indexado por queryKey, así que tres componentes que piden 'products' comparten la misma máquina. No es "un mejor useFetch": es mover el estado de dentro del componente a una caché global. Eso es lo que hace que los tres se mantengan coherentes —y es justo lo que construimos en la lección 7—.
Errores comunes
Manejar solo el caso success y olvidar loading/error. Qué pasa: se escribe el render asumiendo que data ya está, sin las ramas de "cargando" ni "falló". Por qué pasa: en la demo, con red instantánea, data llega tan rápido que "parece" que siempre está. Cómo detectarlo: crashes tipo Cannot read property 'map' of null en el primer render (aún cargando), o pantallas en blanco cuando la red falla. Cómo corregirlo: toda lectura del servidor tiene tres ramas obligatorias —isLoading, isError, y datos—. React Query te las da como flags para que nunca leas data antes de tiempo.
Extraer un useFetch y creer que resolvió la coherencia. Qué pasa: se hace un custom hook para no repetir el boilerplate y se asume que ya no hay problema. Por qué pasa: el código se ve más limpio, y "limpio" se confunde con "correcto". Cómo detectarlo: sigues viendo, en producción, un componente cargando mientras otro con el mismo dato ya lo muestra. Cómo corregirlo: useFetch quita la repetición, no la duplicación de estado: cada componente que lo llama tiene su propia máquina. Para coherencia necesitas estado compartido por queryKey, fuera de los componentes —una capa, no un hook que envuelve useState—.
Mostrar el mismo mensaje para "cargando" y "error". Qué pasa: se colapsan los dos estados sin datos en un genérico "algo salió mal" o un spinner para ambos. Por qué pasa: los dos "no tienen datos", así que se tratan igual. Cómo detectarlo: un spinner que gira para siempre cuando en realidad la petición falló, o un "error" que aparece durante la carga normal. Cómo corregirlo: loading es transitorio (se resuelve solo) y error es terminal (necesita reintentar); son ramas distintas. La máquina de tres caras existe precisamente para separarlas, y isLoading vs isError te dejan pintarlas por separado.
Ejercicios
Ejercicio 1 — El instante incoherente. En la corrida, describe qué ve un usuario en el momento t2 si la página muestra ProductList arriba, FeaturedGrid en medio y SearchResults abajo. ¿Por qué es incoherente, si los tres muestran el mismo /products?
Ver solución
En t2, el usuario ve, de arriba a abajo: el catálogo (ProductList en success, "Mouse, Keyboard"), un mensaje de error con "reintentar" (FeaturedGrid en error: NetworkError), y un spinner (SearchResults en loading, "Cargando..."). Tres presentaciones distintas del mismo dato en la misma pantalla.
Es incoherente porque los tres componentes muestran /products —una sola verdad—, pero cada uno tiene su propia máquina de estados, y esas máquinas avanzaron a ritmos distintos (una ya volvió, otra falló, otra sigue esperando). No hay ninguna razón de negocio para que el catálogo esté disponible arriba y "falle" en el medio: es el mismo dato. La incoherencia es un artefacto de tener tres copias del estado en vez de una. Con una máquina compartida, o los tres cargan, o los tres tienen datos, o los tres muestran error —siempre igual—.
Ejercicio 2 — Cuenta el boilerplate. Una app tiene 12 componentes que hacen fetch, cada uno con su máquina loading/data/error (3 useState + 1 useEffect + 3 ramas de render). (a) ¿Cuántas máquinas de estados hay? (b) Con una capa por queryKey, ¿dónde vive la máquina y cuánto boilerplate escribe cada componente? (c) ¿Qué gana además de menos código?
Ver solución
- (a) Doce máquinas. Cada componente re-implementa la suya: 12 × (3
useState+useEffect+ 3 ramas). Doce copias del mismo aparato. - (b) La máquina vive una vez, en la capa, por
queryKey. Cada componente solo llamaconst { isLoading, isError, data } = useQuery(...)y decide qué pintar con esos flags. El boilerplate de la máquina (el.then, el.catch, el manejo de estados) no lo escribe el componente: lo trae la capa. - (c) Gana coherencia: los componentes que comparten
queryKeycomparten estado, así que no se desincronizan (no hay el instantet2incoherente). Gana caché, dedup, revalidación y descarte de respuestas viejas, que vienen con la misma capa. Y gana menos superficie de bugs: la máquina probada una vez, en vez de 12 oportunidades de escribirla mal.
Ejercicio 3 — useFetch vs capa. Un compañero extrae un custom hook useFetch(url) que encapsula los tres useState y el useEffect, y lo usa en ProductList, FeaturedGrid y SearchResults. ¿Desaparece la incoherencia del t2? ¿Por qué sí o por qué no? ¿Qué haría falta?
Ver solución
No desaparece. useFetch(url) reduce la repetición de código (ya no copias los tres useState y el useEffect en cada componente), pero cada llamada a useFetch('/products') crea su propia instancia de estado dentro de ese componente —tres llamadas, tres máquinas independientes—. Como el estado sigue viviendo dentro de cada componente, las tres pueden avanzar a ritmos distintos y quedar en estados diferentes, igual que en t2. Además, useFetch por sí solo no trae caché, dedup, revalidación ni guard de race: solo empaqueta el boilerplate.
Haría falta que el estado viva fuera de los componentes, en una caché global indexada por queryKey, de modo que las tres llamadas para 'products' compartan la misma entrada (la misma máquina). Entonces los tres leen el mismo { isLoading, isError, data } y no pueden desincronizarse. Eso ya no es "un hook que envuelve useState"; es una capa de estado del servidor —React Query—. La diferencia es dónde vive el estado: dentro del componente (se duplica) o en una caché por llave (se comparte).
Resumen y siguiente paso
En esta lección mediste el quinto mal del fetching manual: la máquina loading/data/error repetida en cada componente. Como pedir es asíncrono y falible, cada componente monta su propia máquina (tres useState, un useEffect, tres ramas), y —lo peor— tres máquinas independientes para el mismo dato pueden quedar en estados distintos a la vez: en t2 viste el mismo /products mostrándose como datos, error y cargando en tres componentes. La cura es una máquina compartida por queryKey: un { isLoading, isError, data } leído por todos, siempre coherente, con el boilerplate escrito una sola vez. Lo anclaste con tres cajeros con su propia libreta frente a un tablero central, y viste por qué un useFetch reduce la repetición pero no la duplicación de estado —solo una capa fuera de los componentes da coherencia—.
Antes de avanzar deberías poder: reconocer el boilerplate loading/error que se repite; explicar por qué tres máquinas independientes se desincronizan para el mismo dato; distinguir useFetch (menos código, mismo estado duplicado) de una capa (estado compartido); y justificar por qué la coherencia viene de mover el estado fuera del componente.
La lección 7 cierra el diagnóstico con la síntesis: los cinco males que mediste —sin caché, sin dedup, race, sin revalidación, loading/error repetido— tienen una sola raíz y una sola cura. Vas a ver, ejecutada, una capa de caché mínima de unas veinte líneas que hace las cinco cosas a la vez, indexando por queryKey, y el mapa que conecta cada mal con lo que la capa hace. Y verás por qué esa capa no hay que inventarla: existe, se llama React Query / TanStack Query, y su mecánica es el módulo 5.
Recursos
- TanStack Query, "Queries" — tanstack.com/query/latest/docs/framework/react/guides/queries. Los estados de una query (
isPending,isError,isSuccess,data,error) —la máquina de esta lección, entregada como flags—. En inglés. - TkDodo, "Status Checks in React Query" — tkdodo.eu/blog/status-checks-in-react-query. Cómo leer bien
isLoading/isError/datay por qué manejarlos a mano en cada componente es frágil. En inglés. - React, "You Might Not Need an Effect" (sección "Fetching data") — react.dev/learn/you-might-not-need-an-effect#fetching-data. El boilerplate del
useEffectde fetching y por qué se delega a una librería. En inglés. - TkDodo, "Why You Want React Query" — tkdodo.eu/blog/why-you-want-react-query. El estado
loading/errorcompartido entre los beneficios de la capa frente al fetching a mano. En inglés.