Infinite Re-Renders: Reading React's Error Like a Pro
Uncaught Error: Maximum update depth exceeded. This can happen when a
component repeatedly calls setState inside componentWillUpdate or
componentDidUpdate. React limits the number of nested updates to
prevent infinite loops.
To debug an infinite rerender in React, the first thing to internalize is that this error’s stack trace is lying to you — or at least, telling you something less useful than it appears. The trace points at wherever React happened to be when it hit its nested-update limit, which is frequently a component that’s completely innocent: it’s just the one that was rendering when the music stopped. The component causing the loop can be anywhere upstream.
Second thing to internalize: there are actually two different errors, and they identify two different crimes.
Too many re-renders. React limits the number of renders to prevent an infinite loop. — you called setState during render. Synchronous, immediate, caught within a single render pass. Usually a one-character bug.
Maximum update depth exceeded. — you have a render → effect → setState → render cycle. Asynchronous, structural, and the interesting one. The old class-component wording about componentDidUpdate survives in the message; in 2026 it almost always means useEffect.
Every infinite loop I’ve debugged in the hooks era falls into one of five shapes. Here they are, each with the smallest repro I can write.
Cause 1: calling setState during render
function Tabs() {
const [active, setActive] = useState(0)
return <button onClick={setActive(1)}>Products</button>
// ^^^^^^^^^^^^ called now, not on click
}
setActive(1) executes during render, triggers a re-render, which executes it again. You wanted onClick={() => setActive(1)}. This produces the “Too many re-renders” variant and it’s the fastest fix in this article: find the handler that’s being invoked instead of passed. Ten seconds once you know to look for it, and even seasoned people write it after their third coffee doesn’t kick in.
Cause 2: useEffect with unstable dependencies
function Results({ query }) {
const [data, setData] = useState([])
const options = { query, limit: 20 } // new object, every render
useEffect(() => {
fetchResults(options).then(setData)
}, [options]) // never "equal", so: every render
}
The mechanism: dependencies are compared with Object.is. A fresh object literal fails that comparison every time, so the effect runs after every render. The effect calls setData, which renders, which creates a new options, which runs the effect. The fetch even succeeds each time — you’re DDoSing your own API while your UI vibrates.
Fix: depend on the primitives, not the container.
useEffect(() => {
fetchResults({ query, limit: 20 }).then(setData)
}, [query])
The same trap wears other costumes: an inline array ([a, b]), an inline function, a .filter() result computed in render. Anything freshly allocated per render is unstable by definition. This is the single most common cause on this list, and it’s why the exhaustive-deps lint rule is not optional in my book — it can’t catch everything, but it catches the ones that page you.
Cause 3: context value churn
Cause 2’s big brother, with blast radius.
function AuthProvider({ children }) {
const [user, setUser] = useState(null)
return (
<AuthContext.Provider value={{ user, setUser }}>
{children}
</AuthContext.Provider>
)
}
Every render of AuthProvider creates a new { user, setUser } object, so every consumer sees a “changed” context. That alone is a performance problem, not a loop — but combine it with any consumer that has the context value in an effect dependency and sets state in response, and now the provider re-renders, mints a new value, and the consumer’s effect fires again. The loop spans two components and neither looks wrong in isolation, which is exactly why it survives code review.
This is the legitimate home of useMemo:
const value = useMemo(() => ({ user, setUser }), [user])
Memoizing a context value isn’t premature optimization — it’s restoring the identity stability that consumers reasonably assume they’re getting.
Cause 4: derived state loops
function Cart({ items }) {
const [total, setTotal] = useState(0)
useEffect(() => {
setTotal(items.reduce((sum, i) => sum + i.price, 0))
}, [items])
// ...
}
This version only loops if items is unstable (see cause 2), but the pattern is a loop waiting for a contributor. Someone adds total to the deps, or a second effect that adjusts items based on total, and now two effects feed each other state updates forever.
The deeper fix isn’t better dependencies — it’s deleting the state. A value computable from existing props or state should be computed in render:
const total = items.reduce((sum, i) => sum + i.price, 0)
No effect, no second state, no loop possible. “Should this be state at all?” resolves more of these than any memoization ever will. State that mirrors other state is a synchronization job you’ve assigned to yourself, and you will eventually miss a shift.
Cause 5: parent–child update cycles
function Parent() {
const [size, setSize] = useState(null)
return <Chart onResize={setSize} height={size?.height ?? 300} />
}
function Chart({ onResize, height }) {
const ref = useRef(null)
useEffect(() => {
onResize(ref.current.getBoundingClientRect())
}, [onResize, height]) // height changes → report size → height changes
}
Child measures itself and reports to the parent; parent stores it and passes a prop back down; the prop change re-triggers the measurement, and if the measured object is fresh each time (a DOMRect always is), the state never settles. The loop crosses a component boundary, which is what makes the error’s stack trace so useless here — it’ll name whichever component was mid-render, not the handshake causing the churn.
Break the cycle at the state update: bail out when the value is meaningfully equal.
onResize={rect => setSize(prev =>
prev?.height === rect.height ? prev : rect
)}
Returning the previous state from an updater tells React to skip the re-render. That single equality check is the difference between “settles after one pass” and “maximum update depth exceeded.”
Actually finding yours
Since the stack trace won’t name the culprit: put a console.count('MyComponent render') in your top few suspects — the counts tell you where the storm is even when the trace doesn’t. React DevTools Profiler does the same with more ceremony; “why did this render?” in its settings is genuinely useful here. Then look at what changed between renders: log the effect deps and eyeball which one never stabilizes. It’s the freshly-allocated one. It’s nearly always the freshly-allocated one.
One dev-mode footnote: Strict Mode double-invokes renders and re-runs effects on mount to surface exactly these bugs. If your “loop” is precisely two renders or two effect runs in development, that’s not a loop — that’s React proving your effect isn’t idempotent, which is a different (real) finding.
And where do useMemo/useCallback fit? They’re the correct fix when a stable identity is the requirement: context values, deps of effects you can’t restructure, props to genuinely expensive memoized children. They’re cargo cult when applied preemptively to everything that allocates — wrapping every handler in useCallback “for performance” adds a dependency array to maintain (a new bug surface) while fixing nothing measurable. My rule: memoize when identity matters or when the profiler says so. Not before.
The five causes share one root: something that should be stable across renders, isn’t. Once you frame the hunt as find the unstable value, most infinite loops fall in minutes. The related failure mode — where the value is stable but time isn’t, and updates land in the wrong order — is a different beast: that’s a race condition, and it deserves its own post.
See the bug the way your user did
LogReplay captures session replays, console output, network requests, and errors in one timeline — so you stop guessing what happened before the ticket arrived.
Try LogReplay free