12k
All articles

How to Fix 'Maximum Update Depth Exceeded' in React

Fix React maximum update depth exceeded errors with one-line fixes for render loops, effect dependency loops, handlers, and throttling.

OpenReplay Team
OpenReplay Team
How to Fix 'Maximum Update Depth Exceeded' in React

“Maximum update depth exceeded” means your component is stuck in an infinite render loop: a state update triggers a re-render that triggers the same state update again, and React aborts after exceeding 50 nested updates (its NESTED_UPDATE_LIMIT) to keep the browser from freezing.

If you have ever watched the tab lock up while the same red line stacks up in the console, you know how unhelpful that wall of text feels at first. The good news is that the fix is almost always a one-line change once you spot which pattern you tripped.

This guide covers the full cause set, including the high-frequency-handler case, with a copy-pasteable before/after for each, a symptom lookup table to route you fast, and the tooling to locate the offending setter in under a minute. The root cause is identical in function and class components: an update loop that never settles.

Key Takeaways

  • The error is an infinite render loop; React stops it after the nested update count exceeds 50 (NESTED_UPDATE_LIMIT in the reconciler source).
  • onClick={handleClick()} calls the function during render and schedules a state update every render. Pass onClick={handleClick}, and onClick={() => handleClick(id)} when you need arguments.
  • The single most reusable fix is a functional update (setCount(prev => prev + 1)), which lets you drop that state from the effect’s dependency array and breaks the read-then-write loop.
  • useCallback stabilizes a function’s identity, not how often it runs, so it will not fix loops caused by high-frequency handlers like onScroll or dnd-kit’s onDragMove; throttle or debounce the state update instead.
  • The class-component #185 error throws in dev and production, but the useEffect variant is a dev-only warning. In production that loop runs with no throw and no console signal.

Symptom → cause → fix lookup

Find the symptom that matches what you’re seeing, then head to the relevant fix.

SymptomCauseFix
Loop with no click, starts on mountonClick={fn()} calls the setter during renderPass onClick={fn}
setState at the top level of the componentState update in the render pathMove it into a handler or effect
Effect fires every renderMissing/wrong deps, or inline object/array in depsCorrect deps + useMemo/useCallback
Effect reads and writes the same stateState is in its own depsFunctional updater; drop the dep
Loop only while dragging/scrolling/resizingsetState on every high-frequency eventThrottle/debounce the update
Parent and child overwrite each otherBidirectional state syncMake the flow one-directional

Fix 1 & 2: handler references and render-path setState

The two fastest wins are in how you wire handlers and where you call the setter. onClick={handleClick()} calls the function during render and schedules a state update on every render; you almost always want onClick={handleClick}, and onClick={() => handleClick(id)} when you need to pass arguments.

// BAD: acceptTerms runs during render, every render
<input type="checkbox" onChange={acceptTerms()} />

// GOOD: pass the reference; wrap in an arrow to pass args
<input type="checkbox" onChange={acceptTerms} />
<button onClick={() => selectItem(item.id)}>Select</button>

The same loop happens when you call a setter directly in the component body. State updates belong in an event handler or an effect, never in the render path.

// BAD: runs on every render → loop
function Counter() {
  const [count, setCount] = useState(0);
  setCount(count + 1);
  return <div>{count}</div>;
}

// GOOD: update in response to an event
const increment = () => setCount(c => c + 1);

Fix 3, 4 & 5: effect dependency loops

Most effect loops come from dependencies that change identity or from an effect that writes the state it reads. An object or array literal written inline in a dependency array gets a new identity on every render, so the effect re-runs every render; wrap it in useMemo (objects/arrays) or useCallback (functions) so its reference stays stable.

// BAD: options is a new object each render → effect re-runs forever
const options = { limit: 10, sort: 'date' };
useEffect(() => { search(query, options).then(setResults); }, [query, options]);

// GOOD: memoize so the reference is stable
const options = useMemo(() => ({ limit: 10, sort: 'date' }), []);

The single most reusable fix is a functional update. When an effect both reads and writes the same state, setCount(prev => prev + 1) reads the previous value from the updater argument instead of the closure, which lets you remove that state from the dependency array and breaks the loop.

// BAD: count is read and written, and it's in deps
useEffect(() => { setCount(count + 1); }, [count]);

// GOOD: functional updater removes the dependency
useEffect(() => { setCount(prev => prev + 1); }, []);

For a function an effect depends on, either wrap it in useCallback with correct deps, or move it inside the effect: a function declared inside the effect is created once per run and doesn’t need to be a dependency.

Fix 6: throttle high-frequency handlers

useCallback stabilizes a function’s identity across renders, but it does not change how often the function runs, so it will not fix an infinite loop caused by a high-frequency handler like onScroll, onMouseMove, onResize, or dnd-kit’s onDragMove. Those events fire dozens of times per second, and each setState schedules another render. Throttle or debounce the state update instead.

// BAD: fires dozens of times/sec while dragging
const handleDragMove = (event) => setDragPreview(compute(event));

// GOOD: cap the update rate; useCallback alone won't help
import { throttle } from 'lodash';
const handleDragMove = throttle((event) => setDragPreview(compute(event)), 100);

lodash’s throttle caps how many times the wrapped function can fire inside a given window; native requestAnimationFrame or a debounce work too. The point is frequency control, not reference stability.

Fix 7: parent-child sync loops

Bidirectional state propagation (a child effect that calls the parent’s setter, which re-renders the child, which fires the effect again) is another classic loop. Lift the state to one owner and keep the data flow one-directional, or transform the value in the parent instead of syncing it back through an effect.

Locate it fast (and the class-component case)

Work top-down: read the stack trace to the named setter at the top of the loop, then open the React DevTools Profiler to find the component re-rendering without pause. Add why-did-you-render (tested against React 19, dev-only, and untested with React Compiler) to see which prop or state changed identity. Catch these at lint time: as of eslint-plugin-react-hooks 7.x, the plugin ships dedicated set-state-in-render and set-state-in-effect rules that catch the two most common triggers before you run the app. Both sit in the default recommended preset, so upgrading the plugin is enough to switch them on; recommended-latest only layers the experimental compiler rules on top. set-state-in-render fires when a component sets state during render with nothing guarding the call, which is the shape that spirals into a loop.

The error is the same root cause in both function and class components; in class components it usually means calling setState during render or unconditionally inside componentDidUpdate. Watch the environment, too: the class-component #185 error throws in both development and production, but the useEffect variant is only a development warning. In the reconciler source, the nested passive-update check sits inside a development-only guard and logs to the console rather than throwing, so a production build runs that effect loop with no throw, no error boundary, and no console signal, and it surfaces only as a frozen tab.

That production gap is where the loop gets expensive to debug. The console shows the error but not which interaction caused it, and for effect loops it may show no error at all. Session replay tools like OpenReplay capture the console error, when present, alongside the sequence of user actions that preceded it, so a bare stack trace (or a silent freeze) becomes a reproducible click-by-click case you can replay against the fixes above.

Once you match your symptom to a row in the table, the change is usually a single line: a reference instead of a call, a useMemo around an object, or a functional updater that lets a dependency go. Wire up exhaustive-deps plus the newer set-state-in-* rules so the next loop fails your lint step instead of your users’ browsers.

FAQs

What is the difference between the useEffect variant of this error and React error #185?

They are two distinct strings with different runtime behavior. Error #185 is the class-component wording, mentioning setState inside componentWillUpdate or componentDidUpdate, and it throws in both development and production. The useEffect variant is a separate dev-only warning from the reconciler source, gated behind a development-only guard, so in production the effect loop runs with no throw, no error boundary, and no console signal at all.

Why does adding useCallback not stop my infinite loop during dragging or scrolling?

Because useCallback stabilizes a function's identity across renders but does not change how often that function executes. A high-frequency handler like onScroll, onMouseMove, or dnd-kit's onDragMove fires dozens of times per second, and each setState schedules another render regardless of whether the function reference is memoized. The fix is frequency control: throttle or debounce the state update itself, using lodash throttle, a debounce, or requestAnimationFrame.

Does the functional updater form always let me remove state from an effect's dependency array?

Only when the effect's sole need for that state is to compute the next value. Writing setCount(prev => prev + 1) reads the previous value from the updater argument instead of the closure, so count can leave the dependency array and the read-then-write loop breaks. If the effect also reads that state for other logic, such as branching or passing it to another function, you still need it as a dependency and must break the loop another way.

Why does the error appear in development but my production build just freezes silently?

Because the useEffect variant's nested passive update guard is wrapped in a development-only check in the React reconciler, so it only emits a console warning during development. In a production build that guard does not run, meaning the same effect loop executes with no thrown error and no console message, surfacing only as a frozen tab or runaway renders. The class-component #185 error is different and throws in both environments.

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.