Home JavaScript React Maximum Update Depth: Break the Loop
Intermediate 5 min · September 23, 2026

React Maximum Update Depth: Break the Loop

Maximum update depth means setState loops every render.

N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Everything here is grounded in real deployments.

Follow
Production
production tested
September 23, 2026
last updated
1,905
articles · all by Naren
Before you start⏱ 12 min
  • React hooks basics: useState, useEffect
  • Browser console and DevTools comfort
  • ESLint available in the project
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • 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.
✦ Definition~90s read
What is React Maximum Update Depth Fix?

Maximum update depth exceeded is React's guard against infinite render loops, thrown when a component chains more than fifty nested updates without settling. Rendering must be a pure computation of output from state and props. When the render path writes state, each write schedules another render, which writes again.

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.

React counts the nesting and aborts with this error instead of hanging the tab. The number fifty is an implementation fuse, not a budget to spend.

Three patterns light the fuse. Calling a state setter during render, such as onClick={setCount(count + 1)} with the call executed instead of deferred, updates on every pass unconditionally. A useEffect with no dependency array runs after every commit, so any state write inside it refires the effect forever.

Inline objects, arrays, or functions in a dependency array compare unequal every render by reference, so the effect reruns and rewrites even when the data never changed.

The unifying concept is convergence. Healthy updates move state toward a fixed point: a fetch effect that writes only when data differs settles after one pass. Looping updates never converge: a setter that always writes a fresh object keeps every pass different from the last.

The fix is always making the update conditional or the trigger stable, so some pass finally changes nothing and the cycle ends.

Tracing beats staring. A render log shows the count climbing, the component stack names the participants, and commenting out one suspect update at a time bisects the cycle in minutes. The sections below reproduce each loop with plain simulations you can run anywhere, then show the guard that settles it.

Plain-English First

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.

render-loop.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function simulate(guarded) {
  let state = 0;
  let renders = 0;
  function render() {
    renders++;
    const next = state + 1;
    const shouldUpdate = guarded ? next < 3 : true;
    if (shouldUpdate && renders < 60) {
      state = next;
      render();
    }
  }
  render();
  console.log((guarded ? 'guarded' : 'unguarded') + ' renders: ' + renders);
}

simulate(false);
simulate(true);
Try it live
📊 Production Insight
These one-character handler bugs survive review because diffs show intent clearly while hiding execution timing. A render-count assertion in component tests catches them mechanically: any interaction rendering more than a handful of times fails the build before users feel it.
🎯 Key Takeaway
Never call setters during render. Defer writes to handlers and effects, and derive values by computing instead of storing.

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.

effect-deps.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function simulateEffect(deps) {
  let commits = 0;
  let runs = 0;
  let userId = 1;
  function commit() {
    commits++;
    const watched = deps ? deps() : null;
    const changed = deps ? watched !== commit.last : true;
    if (changed && runs < 60) {
      runs++;
      commit.last = watched;
      commit();
    }
  }
  commit();
  console.log('effect runs: ' + runs + ' for ' + commits + ' commits');
}

simulateEffect(null);
let uid = 1;
simulateEffect(() => uid);
Try it live
⚠ No Array Means Every Commit
An effect without a dependency array is not a default or a shortcut. It is an explicit instruction to run after every render. Pair that with any state write and you have built a loop on purpose.
📊 Production Insight
Polling hooks are the highest-risk effects because they combine timers, fetches, and writes in one place. Every polling hook deserves three lines of defense: a correct dep array, an interval cleanup, and a payload-equality guard before setState.
🎯 Key Takeaway
No dependency array runs the effect after every commit. List real deps, satisfy exhaustive-deps as an error, and never write state from an unarrayed effect.

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.

identity-trap.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
const a = { page: 1, size: 20 };
const b = { page: 1, size: 20 };
console.log('two literals equal?', Object.is(a, b));

const stable = a;
console.log('same reference equal?', Object.is(a, stable));

function depsKey(page, size) {
  return page + ':' + size;
}
console.log('primitive key stable?', depsKey(1, 20) === depsKey(1, 20));
Try it live
📊 Production Insight
Data-fetching hooks that accept an options object invite this loop at every call site, since callers inline naturally. Design fetch hooks to take primitives or a memoized key, and the loop becomes unconstructable instead of merely discouraged.
🎯 Key Takeaway
Inline literals are new references every render and always trip Object.is. Hoist, memoize, or depend on primitives so stable data compares stable.

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.

guard-settle.jsJAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function run(guarded) {
  let state = { v: 0 };
  let writes = 0;
  const incoming = [{ v: 1 }, { v: 1 }, { v: 1 }, { v: 2 }];
  for (const next of incoming) {
    const changed = next.v !== state.v;
    if (!guarded || changed) {
      state = next;
      writes++;
    }
  }
  console.log((guarded ? 'guarded' : 'unguarded') + ' writes: ' + writes);
}

run(false);
run(true);
Try it live
📊 Production Insight
Payload-equality guards double as performance wins: skipping identical writes skips renders across the whole subtree. The guard that fixes the loop also cuts normal-case rendering, which makes it an easy sell in review.
🎯 Key Takeaway
Skip setters when incoming data matches current state. Guards turn unconditional writes into converging ones and protect against future dep mistakes.

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.

📊 Production Insight
Wallboard and kiosk apps loop visibly before anyone checks logs, since frozen screens photograph well in incident channels. A render-count smoke test on those routes pages before the screenshots do.
🎯 Key Takeaway
Log renders and effect runs, bisect by commenting one write at a time, and confirm with the profiler. Distinguish StrictMode double-invocation from true unbounded loops.

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.

💡Make the Build Say No
Warnings do not prevent loops because deadlines override them. Promote exhaustive-deps to an error, add render-count assertions, and let the pipeline reject what reviews miss.
📊 Production Insight
The teams that never see this error treat render purity like type safety: enforced mechanically, not requested politely. One lint promotion plus one test helper outperforms a year of careful reviews.
🎯 Key Takeaway
Pure renders, deliberate dep arrays, stable references, and converging writes. Enforce all four with lint rules, PR checklists, and count assertions.
● Production incidentPOST-MORTEMseverity: high

A Missing Dep Array Froze Dashboards for 28 Minutes

Symptom
At 3:04 PM, the operations dashboard fleet began freezing: 30 wallboards showed the spinner, laptop fans spun up, and Chrome task manager reported 100 percent CPU on the dashboard tab. The API saw request volume jump from 40 to 2,800 calls per minute as every board re-polled continuously. No deploy had touched the API, and backend latency stayed at 25 ms throughout.
Assumption
The team blamed the API first because request volume spiked 70x. They scaled the API pool from 4 to 10 instances in 9 minutes with no effect. A dashboard deploy from the morning was considered innocent because it passed review and showed only a refactored polling hook with cleaner code.
Root cause
The refactor dropped the useEffect dependency array while converting to an inline fetch helper, so the effect ran after every render. Each fetch wrote a freshly built object into state, and the new object identity rerendered, which refired the effect. Every board looped render-fetch-render at roughly 90 iterations per second, and the fifty-update fuse threw continuously while the browser stayed pegged.
Fix
At 3:32 PM the hook was restored with [pollUrl, intervalMs] dependencies plus a deep-compare guard that skips setState when payloads match. Request volume fell to 40 per minute within 60 seconds. The team then added an ESLint exhaustive-deps gate that fails builds on missing arrays and a render-count assertion in dashboard tests that fails above 5 renders per update.
Key lesson
  • 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.
Production debug guideFive steps that find the update that never says no.5 entries
Symptom · 01
React throws Maximum update depth exceeded
Fix
Read the component stack in the error: it names the looping components. Add a console.log('render', componentName) at the top of each suspect render and reload. The name printing dozens of times per second is your loop. Note what changed in its last commit before touching code.
Symptom · 02
You suspect a render-phase state write
Fix
Search the render path for direct setter calls: grep -rn "set[A-Z]" Component.jsx and check each hit runs during render rather than inside a handler or effect. Calls like onClick={setCount(c + 1)} execute immediately. Wrap them as onClick={() => setCount(c + 1)} so they run on events only.
Symptom · 03
You suspect an effect refiring forever
Fix
Log inside the effect with its deps: console.log('effect ran', JSON.stringify(deps)). If it prints every commit, the array is missing or holds a fresh reference. Add the exhaustive-deps lint output by running eslint on the file, then stabilize deps per the sections below.
Symptom · 04
The effect deps look correct but still refire
Fix
Check for inline objects, arrays, or arrow functions in the array. Each render builds a new reference that fails Object.is comparison. Hoist constants outside the component, memoize with useMemo or useCallback, or compare primitive fields instead of whole objects.
Symptom · 05
You need the loop to settle today
Fix
Add a guard inside the updater: compare incoming values with current state and skip setState when nothing changed. Verify the render log stops climbing, then confirm with the React profiler that commits drop to one per interaction. Keep the guard and fix the trigger as the follow-up.
Maximum Update Depth Causes Compared
Root CauseHow to ConfirmFixPrevention
Setter called during renderRender log climbs with no events firingDefer to handlers with arrow functionsRender-count assertions in tests
Effect with no dependency arrayEffect log prints after every commitAdd the true dep listexhaustive-deps as a build error
Inline object or fn in depsEffect refires though data never changesHoist, memoize, or use primitivesFetch hooks that take primitive keys
Unguarded repeat writesIdentical payloads written every passSkip setState when values matchEquality guards as standard hook code
Missing effect cleanupTimers or subs pile up across rendersReturn cleanup that clears themFake-timer tests proving cleanup runs
⚙ Quick Reference
4 commands from this guide
FileCommand / CodePurpose
render-loop.jsfunction simulate(guarded) {The Loop
effect-deps.jsfunction simulateEffect(deps) {useEffect Without a Dependency Array Refires Forever
identity-trap.jsconst a = { page: 1, size: 20 };Inline Objects as Deps
guard-settle.jsfunction run(guarded) {Guard Conditions

Key takeaways

1
The error means updates never converge, with fifty nested renders as the fuse.
2
Never call setters during render; defer writes to handlers and effects.
3
Every effect needs a deliberate dep array with exhaustive-deps as an error.
4
Inline objects are fresh references that refire effects; use primitives or memoize.
5
Guard writes by skipping setState when data matches current state.
6
Trace with render logs plus the profiler, then lock fixes with count assertions.

Common mistakes to avoid

5 patterns
×

Passing setCount(x) instead of () => setCount(x) to handlers

Symptom
The component loops from mount with no user interaction at all.
Fix
Wrap the call in an arrow function so it runs on the event, not during render.
×

Leaving the dependency array off a data-fetch effect

Symptom
Fetches fire per commit and request volume multiplies until the fuse throws.
Fix
Add the real deps and promote exhaustive-deps from warning to error.
×

Putting inline objects into dependency arrays

Symptom
Effects refire every render despite visibly unchanged data.
Fix
Depend on primitives like [page, size] or memoize the object with useMemo.
×

Writing fetch results without comparing first

Symptom
Identical payloads retrigger renders and keep borderline effects spinning.
Fix
Guard with field or version comparison and skip setState when nothing changed.
×

Forgetting cleanup for intervals and subscriptions

Symptom
Stale timers write to unmounted or re-rendered state and pile up per pass.
Fix
Return a cleanup function that clears timers and unsubscribes on every re-run.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What does Maximum update depth exceeded mean?
Q02JUNIOR
Why does onClick={setCount(count + 1)} loop?
Q03SENIOR
Why do inline objects in deps refire effects?
Q04SENIOR
What is a guard condition and why does it settle loops?
Q05SENIOR
How do you trace which update loops in a large tree?
Q01 of 05JUNIOR

What does Maximum update depth exceeded mean?

ANSWER
A component chained more than fifty nested updates without settling. Some write in the render path or an effect refires unconditionally, so each pass schedules the next. React aborts to save the tab.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Is fifty updates a budget I can use deliberately?
02
Why does my effect run twice in development?
03
Can useMemo or useCallback fix my loop?
04
Should data fetching live in effects at all?
05
Why does the loop spike API traffic?
06
How do I test that a loop stays fixed?
N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Everything here is grounded in real deployments.

Follow
Verified
production tested
September 23, 2026
last updated
1,905
articles · all by Naren
🔥

That's React.js. Mark it forged?

5 min read · try the examples if you haven't

Previous
HTTP 403 Forbidden Fix
49 / 50 · React.js
Next
React Unique Key Prop Warning Fix