Fix React useEffect Infinite Loops: Root Causes and Minimal Checks
Quick Answer
- Root cause: Infinite loops occur when a state update inside
useEffecttriggers a re-render, which re-runs the effect because a dependency changed, creating an endless cycle. - First checks: Verify your dependency array includes every variable used inside the effect, and ensure no object/array literals or inline functions are passed as dependencies (they change on every render).
- Minimal fix: Add missing dependencies to the array, or use
useMemo/useCallbackto stabilize object/function references before passing them as dependencies. - Applicable environment: React 16.8+ with Hooks; be aware that React 18 Strict Mode intentionally double-invokes effects in development to surface bugs.
What Problem It Solves
React's useEffect Hook lets you perform side effects in function components. When the dependency array is missing, incorrect, or contains unstable references, the effect runs on every render instead of only when intended. This causes:
- Infinite re-render loops that crash the browser with "Maximum update depth exceeded"
- Unnecessary API calls and performance degradation
- Stale closures that read outdated state or props
This guide provides a practical, 5-minute checklist to identify and fix the three most common infinite-loop patterns.
Root Cause Analysis
The useEffect Hook runs after every render where any dependency in its array has changed. An infinite loop happens when:
- The effect updates state
- The state update triggers a re-render
- The re-render produces a new value for a dependency
- The effect runs again because the dependency changed
- Go to step 1
Three patterns cause unstable dependencies:
| Pattern | Example | Why It Loops |
|---|---|---|
| Missing dependency | useEffect(() => setCount(count + 1), []) | Effect reads count but doesn't list it; React warns but the value is stale. If you add it, the effect runs every time count changes, creating a loop. |
| Object/array literal | useEffect(fn, [{ key: value }]) | A new object is created on every render, so the dependency is always "changed." |
| Inline function | useEffect(fn, [() => {}]) | A new function reference is created on every render. |
Minimal Working Configuration
Pattern 1: State Update Without Conditional Guard
JSX// ❌ Infinite loop: setState runs unconditionally useEffect(() => { setCount(count + 1); }, [count]); // ✅ Fix: add a condition to stop the loop useEffect(() => { if (count < 10) { setCount(count + 1); } }, [count]);
Pattern 2: Object or Array as Dependency
JSX// ❌ Infinite loop: new object every render const config = { theme: 'dark' }; useEffect(() => { fetchData(config); }, [config]); // ✅ Fix: memoize the object const config = useMemo(() => ({ theme: 'dark' }), []); useEffect(() => { fetchData(config); }, [config]);
Pattern 3: Function as Dependency
JSX// ❌ Infinite loop: new function every render const handleClick = () => setOpen(!open); useEffect(() => { window.addEventListener('click', handleClick); return () => window.removeEventListener('click', handleClick); }, [handleClick]); // ✅ Fix: memoize the function const handleClick = useCallback(() => setOpen(prev => !prev), []); useEffect(() => { window.addEventListener('click', handleClick); return () => window.removeEventListener('click', handleClick); }, [handleClick]);
Common Errors and Fixes
Error: "React Hook useEffect has a missing dependency: 'xxx'"
Solution: Add the missing dependency to the array. If you intentionally omit it, use // eslint-disable-next-line react-hooks/exhaustive-deps with a comment explaining why.
JSXuseEffect(() => { // This effect should only run once on mount fetchInitialData(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []);
Error: "Maximum update depth exceeded"
Solution: Check if setState is called unconditionally inside the effect. Add a conditional guard or use useRef to track whether the effect has already run.
JSXconst hasRun = useRef(false); useEffect(() => { if (!hasRun.current) { hasRun.current = true; setData(fetchData()); } }, []);
Error: "React Hook useEffect has a spread element in its dependency array"
Solution: List each property individually instead of spreading an object.
JSX// ❌ Not supported useEffect(fn, [...obj]); // ✅ Correct useEffect(fn, [obj.a, obj.b]);
Error: "Can't perform a React state update on an unmounted component"
Solution: Use a cleanup function with a cancellation flag or AbortController.
JSXuseEffect(() => { let cancelled = false; const controller = new AbortController(); fetch('/api/data', { signal: controller.signal }) .then(res => res.json()) .then(data => { if (!cancelled) setData(data); }) .catch(err => { if (err.name !== 'AbortError' && !cancelled) setError(err); }); return () => { cancelled = true; controller.abort(); }; }, []);
Production Notes and Security Checks
- React 18 Strict Mode: In development, effects run twice to detect bugs. If your code breaks under double invocation, fix the cleanup logic—don't disable Strict Mode.
- Memory leaks: Always return a cleanup function for subscriptions, timers, and async operations.
- Security: Never pass unsanitized user input directly as a dependency without validation. An attacker could craft input that causes excessive re-renders or exposes internal state.
- Performance: Avoid large objects or arrays as dependencies. Memoize them with
useMemoor restructure to use primitive values. - Server-side rendering (SSR):
useEffectdoes not run during SSR. If your component relies on effects for initial data, use a data-fetching library like React Query or SWR that handles SSR hydration.
FAQ
Q: In React 18 Strict Mode, why does useEffect run twice? Is this a bug?
A: This is intentional. Strict Mode double-invokes effects (including cleanup) in development to help you find bugs: missing cleanup functions, non-reentrant side effects, and memory leaks. Production builds do not double-invoke. If your code breaks, fix the effect logic rather than disabling Strict Mode.
Q: What's the difference between useEffect and useLayoutEffect? When should I use useLayoutEffect?
A: useEffect runs asynchronously after the browser paints; useLayoutEffect runs synchronously after DOM mutations but before the browser paints. Use useLayoutEffect when you need to read layout (e.g., element dimensions) and synchronously trigger a re-render to avoid visual flicker. For most cases, useEffect is sufficient. Note: useLayoutEffect warns in SSR environments.
Q: How do I correctly use async/await inside useEffect?
A: Do not make the effect callback itself async—it returns a Promise, but useEffect expects either undefined or a cleanup function. Define and call the async function inside the effect:
JSXuseEffect(() => { const fetchData = async () => { try { const result = await api.getData(); setData(result); } catch (error) { setError(error); } }; fetchData(); return () => { /* cleanup */ }; }, [deps]);
Always include cancellation logic (via AbortController or a boolean flag) to prevent state updates on unmounted components.