How to Fix 'Maximum Update Depth Exceeded' in React useEffect (Infinite Re-render Loop)
Bug Severity: CRITICAL | UX/User Impact: FULL OUTAGE (browser tab freeze/crash) | Time to Fix: 10–20 mins
TL;DR
- What broke: A
useEffectis callingsetStateon every render because its dependency array contains an unstable reference (object, array, or function recreated on each render), or has no dependency array at all — creating an infinite render loop. - How to fix it: Stabilize the dependency with
useCallback/useMemo, add a conditional guard inside the effect, or correct the dependency array so the effect only fires when the value actually changes. - Fast path: Use our Client-Side Sandbox below to auto-refactor this — paste your component and get the corrected code without sending your logic to a third-party server.
The Incident (What Does the Error Mean?)
Raw browser console output:
Warning: Maximum update depth exceeded. This can happen when a component
calls setState inside useEffect, but useEffect either doesn't have a
dependency array, or one of the dependencies changes on every render.
at YourComponent (YourComponent.jsx:12)
React enforces a hard re-render depth limit (currently ~50 synchronous nested updates). When that ceiling is hit, React bails out and throws, leaving the component in a broken, unmounted, or frozen state. The user sees a blank screen or a completely unresponsive UI. This is not a warning you can ignore in production — it is a functional crash.
The Attack Vector / Blast Radius
This isn't just a cosmetic flicker. The cascade looks like this:
- Component mounts →
useEffectruns →setStatecalled → component re-renders. - Re-render recreates the object/array/function passed as a dependency → React sees a "new" reference → effect fires again →
setStatecalled again. - Loop hits React's depth limit → React throws, the entire component tree below the nearest error boundary unmounts.
- No error boundary? The whole page goes blank. On low-end mobile, the browser tab crashes before React's limit is even reached.
Common triggers ranked by frequency in production codebases:
| Trigger | Example |
|---|---|
| Object literal in deps | useEffect(() => {}, [{ id: userId }]) |
| Inline function in deps | useEffect(() => {}, [() => fetch(url)]) |
| Missing conditional guard | useEffect(() => { setData(transform(data)) }, [data]) where transform returns a new array every call |
| State set unconditionally | useEffect(() => { setCount(count + 1) }, [count]) |
How to Fix It (The Solution)
Basic Fix — Add a Conditional Guard
If you're deriving state from props/other state inside an effect, check before you set.
useEffect(() => {
- setFormattedData(formatData(rawData));
+ const result = formatData(rawData);
+ if (result !== formattedData) {
+ setFormattedData(result);
+ }
}, [rawData, formattedData, formatData]);
⚠️ This is a band-aid. The real fix is eliminating the unstable reference.
Enterprise Best Practice — Stabilize References with useMemo / useCallback
Scenario: function dependency recreated on every render
- const fetchConfig = { endpoint: '/api/data', timeout: 5000 };
+ const fetchConfig = useMemo(() => ({ endpoint: '/api/data', timeout: 5000 }), []);
useEffect(() => {
fetchData(fetchConfig).then(setData);
}, [fetchConfig]);
Scenario: callback passed as dependency
- const handleTransform = (items) => items.filter(i => i.active);
+ const handleTransform = useCallback((items) => items.filter(i => i.active), []);
useEffect(() => {
setVisible(handleTransform(items));
}, [items, handleTransform]);
Scenario: deriving state from props — skip the effect entirely
- useEffect(() => {
- setActiveItems(items.filter(i => i.active));
- }, [items]);
- const [activeItems, setActiveItems] = useState([]);
+ // Derived state belongs in useMemo, not useState + useEffect
+ const activeItems = useMemo(() => items.filter(i => i.active), [items]);
This last pattern eliminates the effect entirely. If state can be derived synchronously, it should never live in useState fed by useEffect. This is the single most common cause of this error in mid-to-large codebases.
💡 Tired of pasting proprietary configs into ChatGPT? Generic AI tools log your company's component logic, API endpoints, and internal data shapes. StackEngine is a zero-backend, pure Client-Side WASM utility. Drop your failing component into the sandbox above. We analyze your code locally in the browser and auto-generate the refactored component using your own API key — nothing leaves your machine.
Prevention in CI/CD
This class of bug is 100% statically detectable. There is no excuse for it reaching production.
1. eslint-plugin-react-hooks — non-negotiable, set to error not warn
// .eslintrc.json
{
"plugins": ["react-hooks"],
"rules": {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "error"
}
}
exhaustive-deps at error level will block the build if a dependency array is incomplete or contains an unstable inline reference.
2. Block the PR in CI
# .github/workflows/lint.yml
- name: Lint React Hooks
run: npx eslint --ext .js,.jsx,.ts,.tsx src/ --max-warnings 0
--max-warnings 0 ensures zero-tolerance. A single hooks violation fails the pipeline.
3. React DevTools Profiler in Staging
Enable the "Record why each component rendered" option in React DevTools Profiler. Any component rendering more than 3 times in a single user interaction with setState as the trigger is a candidate for this bug. Make profiler review a mandatory step in your staging QA checklist.
4. Sentry / Datadog RUM — catch it before users do
The Maximum update depth exceeded error surfaces as an unhandled exception. Configure your error monitoring to alert on this string with P1 priority. It should never be treated as a warning in production telemetry.