¿Coincide con tu síntoma?
- El spinner sigue girando y la página nunca se muestra
- El texto 'Loading...' no desaparece nunca
- Recargar no cambia nada
- La consola de DevTools (F12) muestra un error en rojo, o la pestaña Network muestra una petición fallida (en rojo)
Un spinner infinito casi siempre significa lo mismo: los datos nunca terminaron de cargar y la bandera de carga nunca se desactivó. Hay 4 causas comunes — y la mayoría se arreglan con dos líneas de código.
Causa 1 — La carga nunca se limpia cuando la petición falla (la más común)
Código típico:
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/data')
.then(res => res.json())
.then(data => {
setData(data);
setLoading(false); // only runs on success
});
}, []);
Si la petición falla (caída de red, bloqueo de CORS), la ejecución nunca llega a .then, así que setLoading(false) nunca se ejecuta y el spinner gira para siempre. Según MDN, fetch rechaza su promesa ante errores de red y URLs inválidas (los fallos de CORS también aparecen como errores de red).
Solución: limpia la carga en finally
Envuelve la llamada en try / catch / finally para que el bloque finally se ejecute siempre — tanto si hay éxito como si hay error. MDN confirma que finally (y Promise.prototype.finally) se ejecuta tanto si la promesa se cumple como si se rechaza, lo que lo convierte en el lugar ideal para tareas de limpieza como desactivar la bandera de carga.
useEffect(() => {
const load = async () => {
try {
setLoading(true);
const res = await fetch('/api/data');
if (!res.ok) throw new Error(`HTTP ${res.status}`); // see Cause 2
setData(await res.json());
} catch (e) {
setError(e.message); // show an error state
} finally {
setLoading(false); // always runs → spinner always stops
}
};
load();
}, []);
¿Usas una cadena de .then()? Añade .finally(() => setLoading(false)) al final.
> Si es un error de CORS ('blocked by CORS policy' en la pestaña Network), finally detiene el spinner pero aun así no obtendrás los datos. La solución real está en el servidor: la API debe enviar una cabecera Access-Control-Allow-Origin — esto no se puede arreglar desde el frontend.
Causa 2 — Un 404/500 se trata como éxito
fetch() no lanza una excepción ante estados de error HTTP como 404 o 500 — la promesa se resuelve con normalidad. Entonces intentas hacer res.json() sobre una página de error (HTML) y se rompe, o renderizas con datos vacíos. MDN: una promesa de fetch no se rechaza ante un 404/504, así que debes comprobar Response.ok / Response.status tú mismo.
Solución: comprueba siempre res.ok
Es la línea if (!res.ok) throw ... de arriba. Ahora un 404 cae en catch, así que ves un estado de error en lugar de un spinner interminable.
Causa 3 — useEffect entra en bucle infinito
Un array de dependencias incorrecto hace que el componente se vuelva a renderizar → vuelva a hacer fetch → se vuelva a renderizar… y el spinner no se detiene.
Dos errores comunes:
useEffect(() => { /* ... */ }); // 🚩 no array → runs after every render
useEffect(() => { /* ... */ }, [options]); // 🚩 options is an object → new on every render
Documentación de React: los objetos, arrays y funciones se recrean en cada render, así que ponerlos en el array de dependencias hace que el valor sea distinto en cada commit — un bucle infinito.
Solución
- Ejecutar una sola vez → pasa un array vacío
[]:useEffect(() => { ... }, []); - Crea objetos/funciones dentro del Effect; deja solo primitivos (cadenas, números) en las dependencias:
useEffect(() => {
const options = { userId, roomId }; // created inside the Effect
// ...
}, [userId, roomId]); // primitives only
- Actualiza el estado a partir de sí mismo con la forma de updater:
setCount(c => c + 1)(nosetCount(count + 1)con[count], que puede provocar un bucle).
Causa 4 — Respuestas fuera de orden (cambio rápido de pestaña/búsqueda)
Cambiar un input rápidamente puede hacer que una petición antigua se resuelva la última y deje el estado bloqueado. El patrón oficial de React usa una bandera ignore de limpieza:
useEffect(() => {
let ignore = false;
const load = async () => {
try {
const res = await fetch(`/api/data?id=${id}`);
const data = await res.json();
if (!ignore) setData(data);
} finally {
if (!ignore) setLoading(false);
}
};
load();
return () => { ignore = true; }; // cleanup: ignore stale responses
}, [id]);
Lista de comprobación en 3 minutos
- F12 → Console: ¿algún error en rojo? Ese mensaje es la causa real.
- F12 → Network: ¿alguna petición fallida (en rojo)? ¿Es un 404 / 500 / un error de CORS?
- ¿Está
setLoading(false)dentro de un finally? Si no → Causa 1. - ¿Compruebas
res.ok? Si no → Causa 2. - ¿Tiene
useEffectun array de dependencias correcto? Si falta o es un objeto → Causa 3.
> Solución confirmada: las causas 1–3 son las soluciones estándar, verificadas con la documentación oficial de React y MDN. La mayoría de los spinners infinitos se resuelven con dos líneas — 'limpiar la carga en finally' + 'comprobar res.ok'.
