Los bucles infinitos en useEffect ocurren cuando una dependencia cambia en cada render, haciendo que el efecto se re-ejecute sin fin. Las causas más comunes son arrays de dependencias faltantes, referencias de objetos/arrays que crean nuevas instancias en cada render, y llamadas a setState dentro de efectos que disparan re-renders. Se corrigen usando primitivos en las dependencias, useMemo/useCallback para referencias estables, y flags de cleanup en efectos de fetch.
Abres la pestaña Network y ves cientos de solicitudes idénticas disparándose en rápida sucesión. La pestaña del navegador apenas responde. El componente que parecía perfecto hace un minuto ha bloqueado toda tu aplicación en un bucle infinito de renders — todo por un simple objeto dentro del array de dependencias de useEffect. Es uno de los escenarios de depuración más comunes en React, y la solución es directa una vez que entiendes qué está pasando.
Este artículo te lleva por cada patrón común que causa bucles infinitos en useEffect y cómo corregir cada uno. Al final, sabrás leer el ciclo de renderizado, detectar las trampas antes de que te atrapen, y depurar bucles infinitos cuando se cuelen.
¿Por Qué useEffect Causa Bucles Infinitos?
Antes de arreglar nada, ayuda entender la mecánica. El ciclo de renderizado de React con useEffect sigue una secuencia estricta:
- El componente se renderiza (se evalúa el JSX, se actualiza el DOM)
- React ejecuta los efectos cuyas dependencias cambiaron
- Si un efecto llama a
setState, el componente se re-renderiza - Volver al paso 1
Cuando ese ciclo no tiene condición de salida, obtienes un bucle infinito.
El array de dependencias es el mecanismo de React para romper este ciclo. Le dice a React: "solo vuelve a ejecutar este efecto cuando estos valores específicos cambien". Si te equivocas — omites un valor, incluyes uno que cambia en cada render — la condición de salida desaparece.
Los tres errores más comunes que rompen el ciclo:
- Omitir el array de dependencias por completo — el efecto se ejecuta después de cada render
- Poner objetos o arrays en el array de dependencias — JavaScript los compara por referencia, no por valor, así que se ven "diferentes" en cada render incluso cuando los datos son idénticos
- Llamar a
setStatedentro de un efecto de fetch sin protegerlo — la actualización de estado dispara un render, que re-ejecuta el fetch, que actualiza el estado de nuevo
Veamos cada uno con código real.
¿Qué Pasa Cuando Omites el Array de Dependencias?
Este es el error más común, y el más fácil de entender. Cuando omites el array de dependencias por completo, React trata el efecto como "ejecutar después de cada render". Si ese efecto también establece estado, has creado un bucle de renderizado autosuficiente.
La versión rota:
// ❌ No dependency array — runs after every render
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => setUser(data)); // This triggers a re-render...
// ...which runs the effect again, which fetches again
}); // <-- No dependency array here
return <div>{user?.name}</div>;
}
La solución:
// ✅ Dependency array with userId — only re-runs when userId changes
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => setUser(data));
}, [userId]); // <-- Only re-runs when userId changes
return <div>{user?.name}</div>;
}
Un modelo mental útil: el array de dependencias es una lista de "las cosas que le importan a este efecto". Si userId cambia, quieres un fetch nuevo. Si userId permanece igual, no. Escribe esa intención en el array explícitamente.
La regla react-hooks/exhaustive-deps de ESLint detecta dependencias faltantes automáticamente. Si no la estás usando, añade eslint-plugin-react-hooks a tu proyecto — te salvará de este patrón cada vez.
Si genuinamente quieres un efecto que se ejecute solo una vez al montarse, pasa un array vacío []. Eso señala explícitamente "sin dependencias — ejecutar una vez y parar".
// ✅ Run once on mount only
useEffect(() => {
// initialization logic
}, []);
¿Por Qué los Objetos y Arrays en el Array de Dependencias Causan Bucles?
Esta es la trampa que me atrapó en aquel dashboard. Es sutil porque el código se ve completamente razonable — estás poniendo lo correcto en el array de dependencias. El problema es cómo JavaScript compara objetos.
Cuando React verifica si una dependencia cambió, usa Object.is() — esencialmente ===. Para valores primitivos como strings y números, esto funciona perfectamente: "hello" === "hello" es true. Pero para objetos y arrays, === compara referencias, no contenido.
const a = { id: 1 };
const b = { id: 1 };
console.log(a === b); // false — different references, same data
Cada vez que tu componente se renderiza, los literales de objetos y arrays crean nuevas referencias. Incluso si los datos dentro son idénticos, React ve una dependencia "cambiada" y re-ejecuta el efecto.
La versión rota:
// ❌ Options object created on every render — always a new reference
function SearchResults({ query }: { query: string }) {
const [results, setResults] = useState([]);
const options = { query, limit: 10 }; // New object on every render
useEffect(() => {
fetchSearch(options).then(setResults);
}, [options]); // options is always "new" — infinite loop
return <ul>{results.map((r) => <li key={r.id}>{r.title}</li>)}</ul>;
}
Solución 1: Usa los valores primitivos directamente
// ✅ Use primitives, not the object wrapper
function SearchResults({ query }: { query: string }) {
const [results, setResults] = useState([]);
useEffect(() => {
fetchSearch({ query, limit: 10 }).then(setResults);
}, [query]); // query is a string — stable comparison works fine
return <ul>{results.map((r) => <li key={r.id}>{r.title}</li>)}</ul>;
}
Solución 2: useMemo para referencias estables de objetos
Cuando el objeto es genuinamente complejo y no puedes descomponerlo, useMemo crea una referencia estable que solo cambia cuando sus propias dependencias cambian:
// ✅ useMemo gives you a stable reference
function SearchResults({ query, userId }: { query: string; userId: string }) {
const [results, setResults] = useState([]);
const options = useMemo(
() => ({ query, userId, limit: 10 }),
[query, userId] // options only changes when query or userId changes
);
useEffect(() => {
fetchSearch(options).then(setResults);
}, [options]);
return <ul>{results.map((r) => <li key={r.id}>{r.title}</li>)}</ul>;
}
Solución 3: useCallback para dependencias de funciones
Las funciones tienen el mismo problema de referencias. Una función definida dentro del cuerpo de un componente es una función nueva en cada render:
// ❌ New function reference on every render
function DataLoader({ id }: { id: string }) {
const [data, setData] = useState(null);
const loadData = async () => { // New function every render
const result = await fetchById(id);
setData(result);
};
useEffect(() => {
loadData();
}, [loadData]); // loadData is always "new" — infinite loop
}
// ✅ useCallback stabilizes the function reference
function DataLoader({ id }: { id: string }) {
const [data, setData] = useState(null);
const loadData = useCallback(async () => {
const result = await fetchById(id);
setData(result);
}, [id]); // loadData only changes when id changes
useEffect(() => {
loadData();
}, [loadData]);
}
¿Cómo Obtener Datos en useEffect Sin un Bucle Infinito?
Obtener datos es una de las cosas más comunes que harás en un useEffect. También es un campo minado para bucles infinitos. Aquí está el patrón correcto completo, con algunos extras importantes.
La versión rota:
// ❌ Infinite loop — fetching updates state, state triggers re-render, re-render triggers fetch
function PostList() {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
setLoading(true); // setState triggers re-render
fetch("/api/posts")
.then((res) => res.json())
.then((data) => {
setPosts(data);
setLoading(false);
});
}, [loading]); // loading was added to silence a lint warning — now it's a loop
}
El patrón correcto:
// ✅ Correct data fetching with cleanup
function PostList() {
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false; // Cleanup flag
async function loadPosts() {
try {
setLoading(true);
const res = await fetch("/api/posts");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (!cancelled) { // Only update state if still mounted
setPosts(data);
setError(null);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err : new Error("Unknown error"));
}
} finally {
if (!cancelled) setLoading(false);
}
}
loadPosts();
return () => {
cancelled = true; // Cancel on unmount or before next effect run
};
}, []); // Empty array — fetch once on mount
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;
}
Dos cosas a notar aquí más allá de simplemente evitar el bucle:
- La flag
cancelledpreviene actualizaciones de estado después de que el componente se desmonta, lo que evita la advertencia "Can't perform a React state update on an unmounted component". loadingno está en el array de dependencias. Eso es intencional —loadinges estado interno gestionado por el efecto, no un disparador para re-ejecutarlo.
Para código en producción, considera librerías como SWR o TanStack Query. Manejan caché, deduplicación, revalidación y todos los casos extremos que la lógica manual de fetch pasa por alto.
¿Qué Es un Closure Obsoleto y Cómo Se Corrige?
Una vez que domines los arrays de dependencias, eventualmente te encontrarás con un problema relacionado: los closures obsoletos. Esto ocurre cuando tu efecto "captura" un valor del momento del render, pero ese valor ha cambiado desde entonces.
// ❌ Stale closure — count is always 0 inside the interval
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log(count); // Always logs 0 — stale closure
setCount(count + 1); // Always sets to 0 + 1 = 1, never increments
}, 1000);
return () => clearInterval(id);
}, []); // Empty array means count is frozen at 0
return <div>{count}</div>;
}
El closure del intervalo capturó count en el momento del render (cuando era 0) y nunca obtiene un valor nuevo. Dos soluciones:
Solución 1: Actualizador funcional de estado
// ✅ Functional updater always has the latest state
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount((prev) => prev + 1); // prev is always current — no closure issue
}, 1000);
return () => clearInterval(id);
}, []); // Empty array is now safe
return <div>{count}</div>;
}
Solución 2: useRef para valores que no deben provocar re-renders
// ✅ useRef stores a mutable value without causing re-renders
function Logger({ label }: { label: string }) {
const labelRef = useRef(label);
useEffect(() => {
labelRef.current = label; // Keep ref in sync
}, [label]);
useEffect(() => {
const id = setInterval(() => {
console.log(labelRef.current); // Always fresh — ref isn't frozen
}, 1000);
return () => clearInterval(id);
}, []); // label doesn't need to be here
return null;
}
¿Cómo Depurar un Bucle Infinito en useEffect?
Cuando un bucle se escapa y no puedes ver inmediatamente por qué, así es como rastrearlo.
Paso 1: console.count para medir la frecuencia de renders
function SuspectComponent({ data }: { data: SomeObject }) {
console.count("SuspectComponent render"); // Counts each render
useEffect(() => {
console.count("useEffect fired");
// your effect logic
}, [data]);
return <div />;
}
Si "SuspectComponent render" y "useEffect fired" están contando rápidamente, tienes un bucle. Si solo "render" está contando, el problema está en algún lugar del ciclo de renderizado mismo, no en el efecto.
Paso 2: React DevTools Profiler
Abre React DevTools → Profiler → haz clic en Record → déjalo correr un segundo → detén. Busca componentes con un alto número de renders y verifica qué causó cada render (DevTools muestra el "por qué" de cada re-render — qué prop o estado cambió).
Paso 3: Registra los valores de las dependencias entre renders
// Utility hook to track which dependency triggered the effect
function useWhyDidYouUpdate(name: string, deps: Record<string, unknown>) {
const prevDeps = useRef<Record<string, unknown>>({});
useEffect(() => {
const changed: string[] = [];
for (const key of Object.keys(deps)) {
if (prevDeps.current[key] !== deps[key]) {
changed.push(
`${key}: ${JSON.stringify(prevDeps.current[key])} → ${JSON.stringify(deps[key])}`
);
}
}
if (changed.length > 0) {
console.log(`[${name}] deps changed:`, changed);
}
prevDeps.current = deps;
});
}
// Usage
function MyComponent({ config }: { config: Config }) {
useWhyDidYouUpdate("MyComponent", { config });
useEffect(() => {
// ...
}, [config]);
}
Esto te dirá exactamente qué dependencia cambió entre renders, y de qué a qué cambió. Si ves config: [object] → [object] con datos que lucen iguales, has encontrado una trampa de referencias.
Paso 4: Búsqueda binaria en las dependencias
Si tienes un efecto complejo con muchas dependencias y no puedes identificar al culpable, comenta una por una:
useEffect(() => {
// ...
}, [
dep1,
// dep2, // comment these out one at a time
// dep3,
// dep4,
]);
Cuando eliminar una dependencia detiene el bucle, ese es tu culpable. Luego corrígelo apropiadamente en lugar de simplemente dejarlo comentado.
FAQ
¿React Compiler elimina la necesidad de useMemo y useCallback?
React Compiler (estable en v1.0) auto-memoiza valores y funciones, así que ya no necesitas envolver todo manualmente en useMemo o useCallback. Sin embargo, no cambia cómo funcionan los arrays de dependencias de useEffect. Todavía necesitas entender la igualdad por referencia para depurar bucles infinitos.
¿Es seguro usar un array de dependencias vacío []?
Sí, cuando intencionalmente quieres que el efecto se ejecute solo una vez al montarse. Pero asegúrate de que el efecto no dependa de props o estado que cambian — si lo hace, tendrás closures obsoletos. La regla exhaustive-deps de ESLint te advertirá si omites una dependencia que el efecto usa.
¿Puedo usar funciones async directamente en useEffect?
No. useEffect espera que su callback retorne nada o una función de cleanup. Una función async retorna una Promise, que React no puede usar como cleanup. Define la función async dentro del efecto y llámala inmediatamente.
¿React Strict Mode causa que useEffect se ejecute dos veces?
Sí, solo en desarrollo. React 18+ invoca intencionalmente los efectos dos veces en Strict Mode para ayudarte a encontrar lógica de cleanup faltante. Esto no sucede en builds de producción. Si la doble ejecución causa problemas, generalmente significa que tu efecto carece de una función de cleanup.
¿Cómo obtengo datos sin useEffect en React moderno?
En frameworks como Next.js, puedes obtener datos en Server Components — sin necesidad de useEffect. Para obtención del lado del cliente, SWR y TanStack Query proporcionan hooks como useSWR y useQuery que manejan caché, deduplicación y revalidación automáticamente.
¿Por qué ESLint advierte sobre dependencias faltantes si mi código funciona?
La regla exhaustive-deps exige que cada valor usado dentro del efecto se declare como dependencia. Incluso si tu código parece funcionar, las dependencias faltantes pueden causar closures obsoletos — bugs donde el efecto lee un valor antiguo en lugar del actual. Trata la advertencia como un bug real, no como ruido.
¿Cuál es la diferencia entre useEffect y useLayoutEffect para bucles infinitos?
Ambos pueden causar bucles infinitos por los mismos mecanismos. La diferencia es el timing: useLayoutEffect se ejecuta sincrónicamente después de las mutaciones del DOM pero antes de que el navegador pinte, mientras que useEffect se ejecuta asincrónicamente después del pintado. Un bucle infinito en useLayoutEffect congelará el navegador más agresivamente porque no hay pintado entre iteraciones.
Conclusión
Los bucles infinitos de useEffect se remontan a la misma causa raíz: el mecanismo de comparación de dependencias de React y cómo JavaScript maneja la igualdad por referencia. Una vez que interiorizas esas mecánicas, los patrones se vuelven predecibles.
Aquí está la referencia rápida:
| Patrón | Causa | Solución |
|---|---|---|
| Sin array de dependencias | Se ejecuta después de cada render | Añadir [] o listar las deps reales |
| Objeto/array en deps | Nueva referencia cada render | Descomponer en primitivos o usar useMemo |
| Función en deps | Nueva referencia cada render | Usar useCallback |
| Bucle fetch + setState | El estado dispara re-render que dispara fetch | Flag de cleanup + array de deps vacío |
| Closure obsoleto | Valor capturado congelado en el momento del render | Actualizador funcional o useRef |
El flujo de depuración que más tiempo ahorra:
- Añade
console.countal principio del componente para confirmar que está en bucle - Abre el Profiler de React DevTools para ver qué está disparando los re-renders
- Usa el hook
useWhyDidYouUpdatepara identificar qué dependencia específica está cambiando - Aplica la corrección adecuada (no solo
eslint-disable)
La regla react-hooks/exhaustive-deps de ESLint es genuinamente tu mejor primera línea de defensa. Actívala si aún no lo has hecho, y trata sus advertencias como errores reales en lugar de ruido.
Si quieres ir más allá, TanStack Query y SWR manejan los patrones de obtención de datos automáticamente — caché, deduplicación, revalidación en segundo plano — para que no estés escribiendo la misma lógica de cleanup a mano en cada componente.
Una vez que hayas interiorizado estos patrones, los hooks dejan de sentirse como un campo minado y empiezan a sentirse como una herramienta bien diseñada. El ciclo de renderizado es predecible; solo es cuestión de aprender a leerlo.
Artículos relacionados: