32blogby StudioMitsu

Cómo Solucionar Bucles Infinitos en useEffect

Diagnostica y corrige bucles infinitos en React useEffect: dependencias faltantes, referencias de objetos, obtención de datos y consejos de depuración.

14 min read
Contenido

Estaba construyendo un dashboard que obtenía datos del usuario al montarse. Todo parecía bien — el componente se renderizaba, la llamada a la API se ejecutaba, los datos llegaban. Entonces miré la pestaña Network. Cientos de solicitudes, bombardeando el servidor en un bucle infinito. La página se había congelado por completo.

¿El culpable? Un solo objeto dentro del array de dependencias de useEffect.

Si has chocado contra un muro similar — un componente re-renderizándose para siempre, una API siendo bombardeada, o la pestaña de tu navegador quedándose sin memoria — este artículo te guiará exactamente por qué sucede y cómo corregir cada patrón común. Al final, sabrás cómo leer el ciclo de renderizado, detectar las trampas antes de que te muerdan, 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:

  1. El componente se renderiza (se evalúa el JSX, se actualiza el DOM)
  2. React ejecuta los efectos cuyas dependencias cambiaron
  3. Si un efecto llama a setState, el componente se re-renderiza
  4. 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 setState dentro 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:

tsx
// ❌ 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:

tsx
// ✅ 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".

tsx
// ✅ 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.

js
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:

tsx
// ❌ 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

tsx
// ✅ 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:

tsx
// ✅ 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:

tsx
// ❌ 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
}
tsx
// ✅ 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:

tsx
// ❌ 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:

tsx
// ✅ 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:

  1. La flag cancelled previene 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".
  2. loading no está en el array de dependencias. Eso es intencional — loading es estado interno gestionado por el efecto, no un disparador para re-ejecutarlo.

Para código en producción, considera librerías como SWR o React 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.

tsx
// ❌ 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

tsx
// ✅ 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

tsx
// ✅ 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

tsx
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

tsx
// 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:

tsx
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.

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ónCausaSolución
Sin array de dependenciasSe ejecuta después de cada renderAñadir [] o listar las deps reales
Objeto/array en depsNueva referencia cada renderDescomponer en primitivos o usar useMemo
Función en depsNueva referencia cada renderUsar useCallback
Bucle fetch + setStateEl estado dispara re-render que dispara fetchFlag de cleanup + array de deps vacío
Closure obsoletoValor capturado congelado en el momento del renderActualizador funcional o useRef

El flujo de depuración que más tiempo ahorra:

  1. Añade console.count al principio del componente para confirmar que está en bucle
  2. Abre el Profiler de React DevTools para ver qué está disparando los re-renders
  3. Usa el hook useWhyDidYouUpdate para identificar qué dependencia específica está cambiando
  4. 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á, React 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.