Maximum Update Depth Exceeded: A Field Guide to Infinite Render Loops
"Maximum update depth exceeded" is React pulling the emergency brake: something scheduled a state update, which caused a render, which scheduled the same update again — fifty times, until React gave up. When it's a setState called directly in render, the fix takes thirty seconds. When it's an identity feedback loop threaded through three components, a context, and a memo, it can eat a sprint.
The four species
After that hunt, and a dozen shorter ones since, every infinite render loop I've seen falls into one of four species.
Species 1: setState during render
function Widget({ config }: { config: WidgetConfig }) {
const [layout, setLayout] = useState(defaultLayout);
if (config.compact) {
setLayout("dense"); // ❌ render → setState → render → ...
}
// ...
}
The direct form is rare in experienced codebases, but its disguises aren't: onChange={handleChange(id)} (calling, not creating, a handler — if it sets state, that's a set during render), or a useMemo that "derives" state by calling a setter. If a value is derivable from props, derive it — const layout = config.compact ? "dense" : defaultLayout — no state, no loop. If it must reset on a prop change, use key to remount instead of syncing.
Species 2: effect updates its own dependency
useEffect(() => {
setFields([...fields, ...injectedFields]); // writes fields...
}, [fields, injectedFields]); // ...and depends on fields
Fix with a functional update and drop the self-dependency, or better, question whether the effect should exist. Most "sync state to other state" effects are derived state in disguise.
Species 3: unstable references in dependencies
This was half of our form-builder bug. Objects and arrays created during render are new every render — {} !== {} — so they re-trigger anything that depends on them:
function RateTable({ filters = {} }) { // ❌ default {} is new each render
const query = useRates(filters); // → refetch → state → render → new {}
}
The default-prop version is nasty because the caller looks innocent. Hoist defaults to module scope (const NO_FILTERS = {}), memoize computed objects, and treat any object literal in JSX props feeding a hook as a suspect.
Species 4: the cross-component feedback loop
And here's the other half — the reason it took two widgets. Each widget reported its computed height to a shared layout context so the canvas could reflow:
// Inside each widget
useEffect(() => {
reportSize(widgetId, measureDOM(ref.current)); // updates context
}, [layoutVersion]); // context bump → re-measure → report → context bump → ...
The Rate Table's height changed by 1px when the Premium Calculator's reflow shifted its column widths — which bumped layoutVersion, which re-measured the calculator, which shifted the table again. Two springs, coupled, oscillating forever. Neither component had a bug in isolation — the loop only existed in the composition.
The fix for feedback loops is a fixed point: make re-reporting an unchanged value a no-op.
function reportSize(id: string, size: Size) {
setSizes((prev) => {
const current = prev[id];
if (current?.h === size.h && current?.w === size.w) {
return prev; // ✅ same object back — React bails out, loop terminates
}
return { ...prev, [id]: size };
});
}
Returning the same reference from a state updater makes React skip the re-render entirely. We also rounded measurements to integers — sub-pixel jitter from getBoundingClientRect had been defeating the equality check. Convergence guaranteed, demo un-frozen.
Finding the loop when the stack trace won't help
The error's stack trace points at React's scheduler, not your code. What actually works:
- React DevTools Profiler — record the freeze (or the moments before the brake), and look at "why did this render?" on the components rendering hundreds of times. The commit list turns the invisible cycle into a visible drumbeat.
- Bisect by unmounting. Our two-widget clue was gold: loops of species 4 need all their participants. Remove components until the loop dies; the last removal names a participant.
- Log identity, not value.
console.log(filters === lastFilters.current)in the suspect component. Values that look identical but comparefalseare your unstable references. - Audit every
eslint-disablefor exhaustive-deps in the render path. (Recurring theme in this series: those suppressions are where the bodies are buried.)
The discipline that prevents all four
One habit unifies the fixes: updates must converge. Any code path that sets state in reaction to rendering — effects that measure, callbacks that report, contexts that fan out — must reach a state where re-running it changes nothing. Derive instead of sync; return prev when nothing changed; keep reference identity stable for anything a hook depends on.
The form builder shipped with a layout system that converges in at most two passes, enforced by a unit test that mounts every widget pair and asserts the render count stays under a ceiling. Paranoid? The demo freeze happened in front of the client's steering committee. Paranoid is fine.
Keep Reading
08 Jul 2026
The Search Box That Showed the Wrong Results: Effect Race Conditions
07 Jul 2026
"Functions Cannot Be Passed to Client Components": Surviving the RSC Boundary
11 Jul 2026
"use client" Creep: How Our Server-First App Quietly Became a 1.2MB SPA
10 Jul 2026
The Dashboard That Died Every Night: Hunting React Memory Leaks