React Maximum Update Depth Exceeded - Fixing the Infinite useEffect Loop
6 min read
“Your component renders once, then again, then again, faster and faster, until the tab freezes and the console fills up with the same warning. Somewhere in a useEffect, setState is feeding the render that triggers it.”
The page loads. A number starts climbing - fast. Within a second or two the
tab becomes unresponsive, and if you get the console open in time, you'll
see the same warning repeated hundreds of times:
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.
React is not exaggerating. That sentence is a literal description of what's
in your code. There's an effect, it calls setState, and something in
your code is feeding that setState back into the effect's own trigger.
Mount. count is 0. Render happens. The effect runs because this is the
first render. Inside, next = count + 1 = 1. setCount(1).
count is now 1, which is different from 0. React re-renders.
count is in the dependency array, and it changed - so the effect runs
again. next = count + 1 = 2. setCount(2).
There is no step in this cycle that breaks it. Every effect run produces a
state change, every state change re-renders, every re-render re-triggers the
effect because the one thing it's watching just changed - by its own hand.
Symptom
A number (or anything driven by the looping state) increases visibly, fast,
for a second or two, and then the tab stops responding. The console fills
with "Maximum update depth exceeded" - React's own circuit breaker, which
stops the loop before it crashes the browser entirely.
Two changes, and either one alone would fix it, but together they make the
intent clear.
The dependency array becomes []. This effect should run once, on
mount, to do its initial setup. It has no reason to re-run every time
count changes - in fact, that was the entire bug.
setCount(next) becomes setCount((prev) => prev + 1). A functional
updater doesn't read count from the effect's closure at all - it asks
React for the current value when the update actually applies. This matters
because even with [], the first render of this effect would otherwise
still close over count as 0 - the functional form makes the effect not
care what count was when it was created.
With both changes: mount, effect runs once, setCount((prev) => prev + 1)
takes 0 to 1, re-render happens - but [] means the effect does not run
again. The count settles at 1 and stays there.
The first example is the "obvious" version - count is visibly in its own
dependency array. This second one is sneakier, because the dependency array
looks like it should be fine.
runCount isn't even in the dependency array. The functional updater is
already being used. And it still loops infinitely.
The problem is options. Every time DataSummary renders, the line
const options = { limit: 10, orderBy: "date" } runs again, allocating a
brand new object at a brand new memory address - even though the
contents are identical to last time.
React compares dependencies with Object.is, which for objects means
"is this the exact same reference," not "does this have the same keys and
values." Object.is(oldOptions, newOptions) is false - always, because
they are two different objects that happen to look alike.
The Sensei's Hint
Walk the cycle the same way as before: effect runs, setRunCount fires,
component re-renders, a newoptions object is created during that
render, React sees a "changed" dependency, effect runs again. The state
update itself doesn't even need to be related to options - any
setState inside the effect is enough to restart the cycle, because the
re-render it causes is what recreates options.
One line moved. options now lives at module scope - it is created exactly
once, when the file is first loaded, and every render of DataSummary
refers to that same single object. Object.is(options, options) is now
trivially true on every comparison, because it's literally the same value
every time.
The effect runs once on mount, setRunCount takes runCount from 0 to
1, the component re-renders, options is still the same module-scope
object it always was, the dependency hasn't "changed," and the effect does
not run again.
If options genuinely needs to depend on something from inside the
component - state or props - module scope won't work, and the right tool is
useMemo to give it a stable reference that only changes when its own
dependencies do.
Every infinite useEffect loop has the same anatomy: something the effect
depends on changes as a result of the effect running, directly or
indirectly. Sometimes that's obvious - the exact state variable is right
there in the array. Sometimes it's hidden behind an object or array literal
that gets rebuilt every render regardless of what the effect does.
Constraints
Before adding anything to a useEffect's dependency array, ask: "could
running this effect cause this dependency to be a different value (or
reference) next render?" If yes, you need either a functional updater
(for primitives derived from their own previous value) or a stable
reference via module scope or useMemo (for objects and arrays) - not
just "add it and see."
"Counter Spins Up and Never Stops" is the textbook version - count feeding
its own dependency array, fixed by [] plus a functional updater. Do this
one first; it's the shape you'll recognize fastest in real code. "Run Counter
Never Stops" is the harder one - nothing in the dependency array looks like
state at all, and the loop only becomes visible once you trace where
options is allocated and how often. Working through both back to back
makes the "is this dependency stable across renders?" question automatic.