Ch. 3 · React

Maximum Update Depth Exceeded in React: Causes and Fixes

Fix 'Maximum update depth exceeded' in React: effects with no or unstable dependencies, setState in componentDidUpdate and ref callbacks.

~7 min readintermediateupdated Oct 4, 2026

React prints one of two messages, and which one you get tells you where the loop is. A synchronous loop crashes the tree with an error:

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.
Text

A loop through useEffect produces a development-only console error while the page keeps spinning:

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.
Text

Both mean the same thing: an update caused a render, and committing that render caused another update, more than 50 times in a row. React 18 uses the same wording. In production the error becomes Minified React error #185 and the effect warning is not printed at all, so the page just becomes slow.

Quick fix checklist

  • Find every useEffect / useLayoutEffect in the named component that calls a setter. Does it have a dependency array?
  • For each dependency, ask: is it an object, array or function created during render? If so it changes every render.
  • Move object creation inside the effect, depend on primitive fields (user.id rather than user), or memoize with useMemo / useCallback.
  • If an effect copies props into state, delete the state and compute the value during render.
  • In class components, guard setState in componentDidUpdate with a comparison against prevProps or prevState.
  • Inline ref callbacks that set state (ref={(el) => setNode(el)}) run on every render; use a stable callback or useRef.

Before you start

You should know how useEffect dependencies work: React compares each value with Object.is against the previous render and re-runs the effect if any differ. Effect dependencies covers this in detail. Have the browser console and React DevTools open.

Why it happens

React counts nested updates: updates scheduled while it is committing, or while it is flushing effects that came from a previous update. The limit is 50. Two counters exist, which is why there are two messages.

Synchronous loops throw. Layout effects, class lifecycles (componentDidMount, componentDidUpdate) and ref callbacks run synchronously during the commit. A setter called there forces another synchronous render and commit before the browser even paints:

class Gallery extends React.Component {
  state = { width: 0 };
  componentDidUpdate() {
    this.setState({ width: this.el.offsetWidth }); // new object, no guard
  }
  // ...
}

function Tooltip() {
  const [, setNode] = useState(null);
  return <div ref={(el) => setNode(el)} />; // new ref callback every render
}
JSX

In the Tooltip, the inline ref callback is a new function each render, so React detaches the old one (calling it with null) and attaches the new one (calling it with the element) on every commit. Each call sets state, which re-renders, which creates another new callback. After 50 rounds React throws and unmounts the tree.

Effect loops warn. Passive effects (useEffect) run after paint, so a loop through them does not block the browser in the same way. React detects it, logs the second message in development, and keeps going. Two shapes cause nearly all of them:

// 1. No dependency array: the effect runs after every render
useEffect(() => {
  setTotal(items.reduce((sum, i) => sum + i.price, 0));
});

// 2. A dependency that is new every render
const query = { page, pageSize: 20 };
useEffect(() => {
  setParams(query);
}, [query]);
JSX

In the second case the dependency array is present, but query is a fresh object on every render, so Object.is(prev, next) is always false. The same applies to arrays ([a, b]), inline functions (() => ...) and anything returned from a function that builds new objects.

There is a third kind of loop that triggers neither message: one where the setter runs asynchronously, such as after a fetch. The update arrives in a later task rather than during commit, so React does not count it as nested. The symptom is an endless stream of requests in the Network tab instead of a console error.

Step-by-step walkthrough

Step 1: Identify which variant you have

An uncaught error with “componentWillUpdate or componentDidUpdate” means a synchronous loop: check class lifecycles, useLayoutEffect and ref callbacks. The console warning mentioning useEffect points at passive effects. If neither appears but the CPU fan spins or requests repeat, look for async loops.

Step 2: Find the component

React 19’s error output names the component (An error occurred in the <Gallery> component), and the warning’s stack shows where the setter was called. In React DevTools, enable “Highlight updates when components render” under the settings gear: the looping component flashes continuously.

Step 3: Log the dependencies

To learn which dependency is changing, log them with a reference check:

const prevDeps = useRef([]);
useEffect(() => {
  const deps = [query, onChange];
  deps.forEach((d, i) => {
    if (!Object.is(d, prevDeps.current[i])) console.log('dep changed:', i, d);
  });
  prevDeps.current = deps;
});
JSX

If a dependency changes every render but its contents look identical, it is being recreated during render.

Step 4: Fix the cause, not the symptom

Choose based on what the effect is doing:

// Derived value: no effect, no state
const total = items.reduce((sum, i) => sum + i.price, 0);

// Object used by the effect: create it inside, depend on primitives
useEffect(() => {
  const query = { page, pageSize: 20 };
  return subscribeToResults(query, setResults);
}, [page]);

// Object needed elsewhere too: memoize it
const query = useMemo(() => ({ page, pageSize: 20 }), [page]);
JSX

For class components, compare before setting:

componentDidUpdate(prevProps) {
  if (prevProps.photos !== this.props.photos) {
    this.setState({ width: this.el.offsetWidth });
  }
}
JSX

For ref callbacks, use useRef if you only need the node, or define the callback once with useCallback. (With hooks, setting an identical primitive also ends a loop, because React bails out when Object.is reports no change. Class setState has no such bail-out, and an object like { width } is new every time anyway.)

Worked scenario

A report page lets a child component own the date range and report it to the parent:

function ReportFilters({ onChange }) {
  const [range, setRange] = useState({ from: '2026-09-01', to: '2026-09-30' });

  useEffect(() => {
    onChange({ ...range, days: 30 });
  }, [range, onChange]);

  return <DateRangeInputs value={range} onChange={setRange} />;
}

function Report() {
  const [filters, setFilters] = useState(null);
  return (
    <>
      <ReportFilters onChange={(f) => setFilters(f)} />
      <ReportTable filters={filters} />
    </>
  );
}
JSX

The console fills with the useEffect warning. Diagnosis: Report passes a new arrow function on every render, so onChange is a changed dependency every time. The effect calls it with a new object, setFilters stores it, Report re-renders, passes yet another arrow, and the effect runs again. In a quick reproduction this loop rendered Report tens of thousands of times in under two seconds.

Wrapping the arrow in useCallback would stop the loop, but the real problem is that the child pushes state upward through an effect. The update belongs in the event that changes the range:

function ReportFilters({ range, onRangeChange }) {
  return <DateRangeInputs value={range} onChange={onRangeChange} />;
}

function Report() {
  const [range, setRange] = useState({ from: '2026-09-01', to: '2026-09-30' });
  const filters = { ...range, days: 30 };
  return (
    <>
      <ReportFilters range={range} onRangeChange={setRange} />
      <ReportTable filters={filters} />
    </>
  );
}
JSX

Now Report owns the range, filters is derived during render, and there is no effect to loop. Changing a date causes exactly one render.

Common mistake

The most tempting fix is deleting dependencies until the loop stops, often with // eslint-disable-next-line react-hooks/exhaustive-deps and an empty array. The loop ends, but the effect now reads stale props and state: when range changes, nothing updates. You traded a visible bug for an invisible one.

The second is adding a flag (if (!didRun.current) { ...; didRun.current = true }) so the effect runs once. That also freezes the effect at its first values, and it breaks under StrictMode’s development remount in confusing ways. Make dependencies stable or remove the effect instead.

For the async variant, adding the response to the dependency array ([data]) is a common way to create the loop in the first place: fetching sets data, which re-runs the fetch.

Verify the behavior

Count renders in a test. A component that loops will render far more than expected:

import { render } from '@testing-library/react';
import { Profiler } from 'react';

test('report renders a bounded number of times', () => {
  const commits = [];
  render(
    <Profiler id="report" onRender={(id, phase) => commits.push(phase)}>
      <Report />
    </Profiler>
  );
  expect(commits).toEqual(['mount']);
});
JSX

The fixed version commits once on mount. Do not expect the broken version to fail neatly: inside a test, act() flushes effects synchronously, so the loop never yields and the test hangs until it times out or runs out of memory. A hanging render test is itself a strong hint of an effect loop. In the browser, the React DevTools Profiler should show one commit per date change, and the Network tab should show one request per real change for any data-fetching effect.

Interview exercise

Why does setCount(count + 1) inside useLayoutEffect with no dependency array crash the tree, while the same code inside useEffect only logs a warning? Which is worse in production?

Answer and reasoning

Layout effects run synchronously during the commit, before the browser paints. A setter there forces React to re-render and commit synchronously again, and the loop never yields to the browser. React counts these nested synchronous updates, and after 50 it throws to avoid freezing the page; that error unmounts the tree unless an error boundary catches it. Passive effects run after paint, in a separate step, so the loop yields between iterations and the page stays technically alive. React tracks those nested passive updates separately and only warns, in development. In production the effect version is arguably worse: nothing is logged, the error never surfaces in monitoring, and the page silently burns CPU (or, for a fetch loop, hammers your API). A good answer also explains the fix: remove the effect if the value can be derived, otherwise give it accurate dependencies that are stable between renders.

Continue learning

Practise with the React interview questions and the React MCQs. Effect dependencies and useEffect pitfalls go deeper on dependency arrays, and memo and reference stability explains why objects and functions change identity. For loops that happen during render rather than after it, see Too many re-renders and Cannot update a component while rendering a different component. On react.dev, read Removing Effect Dependencies and the useEffect reference.

More in React

esc