React Maximum Update Depth: Break the Loop
Maximum update depth means setState loops every render.
20+ years shipping production JavaScript and front-end systems at scale. Everything here is grounded in real deployments.
- ✓React hooks basics: useState, useEffect
- ✓Browser console and DevTools comfort
- ✓ESLint available in the project
- Maximum update depth means your component updates state on every render, which triggers another render, looping until React stops it.
- The usual loops are setState called during render, useEffect with no dependency array, and inline objects used as effect deps.
- Break the loop with guard conditions that skip no-op updates, event handlers instead of render-phase calls, and stable dep references.
- Trace it with a console log per render plus the component stack React prints, then fix the update that never converges.
Think of a thermostat wired backwards: it reads the room, decides it is cold, turns on the heater, and the heater's click makes it re-read the room and decide again, forever. Each decision triggers the next reading with no settling point. React's update loop is that thermostat. Rendering reads state, the read updates state, and the update renders again. The fix is a thermostat with a dead band: only act when the temperature actually changed, and the loop settles.
Your component renders, React throws Maximum update depth exceeded, and the page freezes or the test times out. The stack points everywhere and nowhere: render calls an update that schedules a render that calls the update. You changed one line, maybe a useEffect or an onChange, and now the component cannot settle. It feels like React broke, but React is the fire alarm here, not the fire.
The loop always has the same shape. Something in the render path writes state unconditionally, or an effect refires on every commit because its dependencies are never stable. Each pass looks innocent in isolation. Together they form a cycle with no fixed point, and React's fifty-update fuse blows to save the tab.
This guide breaks the cycle methodically. You will see the setState-in-render loop up close, learn why a missing dependency array refires effects endlessly, understand how inline objects create fresh identities every render, apply guard conditions that make updates converge, and trace the exact cycle with logs and the profiler. Every loop ends the same way: one update learns to say no.
The Loop: setState During Render in Plain Sight
The simplest loop hides in event-handler syntax. Writing onClick={setCount(count + 1)} calls the setter during render instead of handing React a function to call later. The call updates state, React rerenders, the render calls the setter again, and the cycle never pauses. The fix is one pair of braces: onClick={() => setCount(count + 1)} defers the call until the click. Veterans still ship the broken form during rushed edits because the two look nearly identical.
Render-phase writes are not limited to handlers. Computing a value and calling its setter in the same body, setFiltered(filter(items)), updates on every pass. Deriving during render should compute and return, never store. When the derived value genuinely belongs in state, move the write into an effect with correct deps or an event handler, so it runs on change rather than on every output.
The render-must-be-pure rule is the mental model that prevents the whole class. Rendering maps state and props to output with no side effects: no setters, no fetches, no subscriptions. Side effects belong in effects and handlers, which React schedules deliberately. Any write you can see while reading the return statement is a loop candidate until proven otherwise.
The snippet simulates the loop without any library: an unconditional update inside the render step climbs past the fuse, while the guarded version settles. Run it and watch the counts diverge. The same divergence plays out inside React with the identical fix.
useEffect Without a Dependency Array Refires Forever
An effect with no dependency array runs after every committed render by design. That is correct for syncing with external systems on each pass, and catastrophic combined with a state write. The effect fetches, the fetch writes state, the write renders, the render refires the effect. Each link is reasonable and the circle has no exit. The morning-refactor incident above was exactly this shape.
The dependency array is the exit declaration. Listing [userId] tells React to rerun only when the user changes, so the fetch-write cycle settles after one pass per user. An empty array runs once on mount, right for one-time subscriptions with cleanup. No array means every commit, which is almost never what data fetching wants.
Stale closures complicate the choice. An effect that reads a value must list it, or it operates on old data. The exhaustive-deps lint rule computes the correct list mechanically, including callbacks that need useCallback stabilization. Teams that run this rule as a warning collect loops; teams that run it as a build error prevent them.
The snippet models effect scheduling across commits: without deps the effect refires per commit and writes chase renders endlessly, while [userId] settles after the user settles. The shape maps directly onto real hooks with the same one-line fix.
Inline Objects as Deps: New Identity Every Render
React compares effect dependencies with Object.is, which checks reference identity for objects. An inline literal like { page, size } built during render is a brand-new object each pass, unequal to last pass by definition. The effect sees changed deps, reruns, often writes state, and the write renders a fresh literal again. Data never changed and the loop never ends, which makes this the most confusing variant to read.
Inline arrow functions and arrays share the fate. onSuccess={() => ...} and [items.filter(...)] both mint fresh references per render. Passing them into deps or into memoized children defeats the memo and refires the effect. The code reads as stable values while behaving as changing ones, and only the reference lens reveals it.
Three fixes cover every case. Hoist truly static values outside the component so one reference lives forever. Wrap derived values in useMemo and callbacks in useCallback with their own correct deps. Or depend on primitives instead of containers: [page, size] instead of [params]. The primitive form compares by value and stays stable across renders with identical data.
The snippet proves the identity trap in isolation: two literals with equal contents compare unequal, while memoized and primitive forms compare stable. Run it once and inline deps become permanently suspicious, which is exactly the right instinct.
Guard Conditions: Making Updates Converge
Guards are the seatbelt that settles loops even when triggers misbehave. A guard compares the incoming value against current state and skips the setter when nothing changed. Fetches that rewrite identical payloads, toggles that set the same flag, and counters clamped at a max all converge under a guard because some pass finally writes nothing. The loop starves for lack of changes.
Functional updates pair naturally with guards. setState(prev => next === prev ? prev : next) returns the same reference when data matches, and React bails out of rendering on identical state. For objects, compare the fields that matter rather than the reference: IDs, timestamps, or version counters. A version field from the server makes the comparison trivial and honest.
Guards also belong around derived writes. Before syncing props into state, check the prop actually differs from the stored copy. Before writing fetch results, compare against what is displayed. Each check converts an unconditional write into a conditional one, and conditional writes are what let effects with slightly wrong deps settle instead of spin.
The snippet shows the pattern: unguarded writes climb while guarded writes plateau the moment values repeat. Keep guards as permanent code, not debugging scaffolding. They protect against the next refactor that quietly destabilizes a dep.
Tracing the Loop With Logs and the Profiler
Bisect the cycle instead of reading the whole tree. Add a render log to each suspect component and reload: the name flooding the console is the looper. Add an effect log printing its deps: the effect firing per commit names the trigger. Comment out one suspect write at a time and watch the flood stop. Three experiments locate nearly every loop in under ten minutes.
The React DevTools profiler confirms and quantifies. Record an interaction, then read the commit count and durations per component. A healthy interaction commits once or twice. A loop commits dozens of times with identical payloads. The flame graph points at the subtree to guard first, which matters in large trees where logs overwhelm.
StrictMode deserves a clarifying note. In development it double-invokes renders and effects to surface impurity, which can look like a loop but settles immediately. A true loop climbs without bound and throws the fuse error. If the render count doubles and stops, that is StrictMode doing its job. If it climbs past fifty, that is your bug.
Record the trace before fixing. A screenshot of the profiler or a pasted render count in the PR turns a scary incident into a reviewed regression test. Future refactors that reintroduce the cycle fail against the recorded baseline instead of against users.
Rules That Prevent the Loop Forever
Four rules, enforced by tooling, end this error as a category. Keep renders pure: no setters, fetches, or subscriptions in the render body. Give every effect a deliberate dependency array with exhaustive-deps failing the build. Stabilize non-primitive deps through hoisting, memoization, or primitive decomposition. Guard every state write that repeats data so updates converge by construction.
Code review gets a three-line checklist to match. Does this diff call a setter during render. Does this effect write state, and if so, what bounds its refires. Does this dep array contain a fresh reference. Reviewers who ask exactly these stop loops at the PR stage where fixes cost minutes.
Tests close the remaining gap. Render-count assertions fail components that exceed a small commit budget per interaction. Effect tests assert fetch call counts stay at one per dep change. Polling hooks get fake-timer tests proving intervals clean up. Each test encodes one rule so refactors cannot silently drop it.
Adopt the rules as team defaults rather than personal habits. Lint config carries the dep rule, test utilities carry the count assertion, and the checklist lives in the PR template. Loops become something other teams experience.
A Missing Dep Array Froze Dashboards for 28 Minutes
- Effects without dependency arrays are infinite loops waiting for a state write. Lint exhaustive-deps as a build failure, not a warning.
- Request-volume spikes with flat backend latency point at the client polling itself. Check the frontend commit before scaling the backend.
- Render-count assertions in tests catch loops that reviews miss. A test that fails past 5 renders would have blocked this deploy.
| File | Command / Code | Purpose |
|---|---|---|
| render-loop.js | function simulate(guarded) { | The Loop |
| effect-deps.js | function simulateEffect(deps) { | useEffect Without a Dependency Array Refires Forever |
| identity-trap.js | const a = { page: 1, size: 20 }; | Inline Objects as Deps |
| guard-settle.js | function run(guarded) { | Guard Conditions |
Key takeaways
Common mistakes to avoid
5 patternsPassing setCount(x) instead of () => setCount(x) to handlers
Leaving the dependency array off a data-fetch effect
Putting inline objects into dependency arrays
Writing fetch results without comparing first
Forgetting cleanup for intervals and subscriptions
Interview Questions on This Topic
What does Maximum update depth exceeded mean?
Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Everything here is grounded in real deployments.
That's React.js. Mark it forged?
5 min read · try the examples if you haven't