Back to the Library

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

In This Post

The Loop, Step by StepThe Fix: Break the Cycle in Two PlacesWhen `[]` Isn't the Fix: Object DependenciesThe Fix: Give the Object a Stable ReferenceThe General Shape of This BugPractice These Patterns

Practice This Pattern

White Belt

Counter Spins Up and Never Stops

A live count that should settle at 1 on mount instead climbs without limit - the effect's setState feeds its own dependency, re-triggering itself forever.

+10 KI
Enter the Dojo
Blue Belt

Run Counter Never Stops

A run counter that should increment once on mount climbs endlessly instead - an inline object dependency gets a new reference every render, so the effect never stops seeing a change.

+25 KI
Enter the Dojo
BugDojo
BlogFAQ

© 2026. Carved in code.

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.

The Loop, Step by Step

export default function LiveCount() {
  const [count, setCount] = useState(0);
 
  useEffect(() => {
    const next = count + 1;
    setCount(next);
  }, [count]);
 
  return <p>Server count: {count}</p>;
}

Walk through this one render at a time.

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

count changed again. Re-render. Effect runs again. setCount(3).

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.

The Fix: Break the Cycle in Two Places

useEffect(() => {
  setCount((prev) => prev + 1);
}, []);

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.

When [] Isn't the Fix: Object Dependencies

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.

export default function DataSummary() {
  const [runCount, setRunCount] = useState(0);
 
  const options = { limit: 10, orderBy: "date" };
 
  useEffect(() => {
    setRunCount((r) => r + 1);
  }, [options]);
 
  return <p>Data processed: {runCount} times</p>;
}

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 new options 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.

The Fix: Give the Object a Stable Reference

const options = { limit: 10, orderBy: "date" };
 
export default function DataSummary() {
  const [runCount, setRunCount] = useState(0);
 
  useEffect(() => {
    setRunCount((r) => r + 1);
  }, [options]);
 
  return <p>Data processed: {runCount} times</p>;
}

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.

The General Shape of This Bug

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

Practice These Patterns

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