You refresh the page and the screen stays blank. Or maybe a button click sends your CPU fan into overdrive. Then the console spits out the dreaded warning: Maximum update depth exceeded. React has slammed the brakes because your component is trapped in an infinite loop. It is one of the most common errors in React applications, and it usually stems from a simple misunderstanding about when your code actually runs.

To fix it, you need to understand the exact moment a render becomes a re-render, and why state changes must stay out of the render path.

How React Renders Your Component

React builds user interfaces out of components. In modern React, those components are functions. Every time React needs to show your component on the screen, it simply calls that function. Inside the function, you can use state to remember things between renders. State tells React which data belongs to the component and, crucially, when something has changed and the UI needs updating.

When state changes, React schedules a new render. The component function runs again, returns new JSX, and React updates the DOM to match. In normal usage, this cycle is harmless. You click a button, an event handler updates state, React re-renders once, and the user sees the new text or color.

The error appears when a render itself triggers another state update. That new state update triggers another render, which triggers another state update. React tolerates this for a few dozen cycles, then throws the maximum depth error to protect the browser from freezing entirely.

A Quick Look at State

Before dissecting the loop, recall how the useState hook works. It gives you exactly two things: a variable holding the current value, and a function to change that value.

const MessageComponent = () => {
  const [message, setMessage] = useState('Welcome');

  return <h1>{message}</h1>;
};

Here, message is 'Welcome' on the first render. If you later call setMessage('Goodbye'), React notes the change, calls MessageComponent again, and the UI now shows "Goodbye." Everything is fine because nothing in the component body itself is calling the setter automatically. The loop begins when the setter fires during the render phase without an external event.

Calling SetState Directly in the Body

The most direct way to create an infinite loop is to call a state setter function straight inside the component body. Because the component body executes on every render, the setter fires on every render. That new state update causes another render. The cycle spins forever.

Here is what the mistake looks like:

const Counter = () => {
  const [count, setCount] = useState(0);

  setCount(count + 1);

  return <div>{count}</div>;
};

Every time Counter renders, it increments count. React renders again to show the new number, sees setCount(count + 1) again, and increments once more. The fix is straightforward: never call a state setter at the top level of your component during render. State updates should respond to user events or side effects, not to the act of painting the screen. Move that update into an event handler instead:

const Counter = () => {
  const [count, setCount] = useState(0);

  return <button onClick={() => setCount(count + 1)}>{count}</button>;
};

The only exception to calling setters during render is when you are computing new state from props, and even then you should use a different pattern, such as deriving the value directly or using useEffect intentionally.

Passing a Function Call Instead of a Reference

Another frequent cause is a subtle JSX typo. When you attach an event handler, you need to pass the function itself. If you accidentally invoke the function right there in the JSX, it runs immediately during the render cycle.

const Toggle = () => {
  const [isOn, setIsOn] = useState(false);

  const handleToggle = () => setIsOn(!isOn);

  return <button onClick={handleToggle()}>Toggle</button>;
};

By writing handleToggle() with parentheses, you are not giving React a function to call later when the user clicks. You are calling it right now while React is building the virtual DOM. Since handleToggle updates state, the component re-renders. During that re-render, React sees handleToggle() again and calls it again. The loop never ends. The correct version removes the parentheses:

return <button onClick={handleToggle}>Toggle</button>;

If you need to pass arguments, wrap the call in an anonymous function:

return <button onClick={() => handleToggle(true)}>Switch On</button>;

This distinction trips up even experienced developers during refactoring. Keep an eye on those parentheses.

The useEffect Dependency Trap

Effects are the right place for side effects like fetching data, syncing with browser APIs, or manually manipulating the DOM. But useEffect runs after React commits the render to the screen. If your effect updates state, React will re-render. Normally that is fine. It becomes a loop when the effect runs after every render and always updates the same state.

Consider this broken pattern:

const UserProfile = () => {
  const [user, setUser] = useState({});

  useEffect(() => {
    setUser({ name: 'Ada', role: 'Admin' });
  });

  return <div>{user.name}</div>;
};

Because there is no dependency array, this effect runs after every single render. It sets user, which triggers a render. After that render, the effect runs again and sets user again. React detects the spiral and throws the error.

The fix is to tell React when the effect actually needs to run by supplying a proper dependency array. If the effect should only run once on mount, pass an empty array:

useEffect(() => {
  setUser({ name: 'Ada', role: 'Admin' });
}, []);

If the effect depends on a prop or a piece of state, include only that variable in the array. Be careful, though. Including a variable that changes every render will just recreate the same loop through a different door. For example, if you include an object literal in the dependencies and that object is recreated on every parent render, the effect will fire endlessly. In those cases, you may need to move the object creation outside the component or memoize it.

Practical Debugging Steps

When you hit this error, the stack trace can look overwhelming because React has already repeated the cycle dozens of times. Start by reading the top of the trace to find which component is named repeatedly. Then look for state setters in these three places:

  1. The main body of the component, outside any handler or hook.
  2. JSX event attributes where you might have written handler() instead of handler.
  3. useEffect hooks that lack a dependency array or depend on unstable references.

Temporarily comment out each state setter until the error stops. That tells you exactly which update is the culprit. If the setter is inside an effect, ask yourself whether you even need state there. Sometimes developers set local state from props inside an effect when they could simply use the prop directly in the JSX.

The Real Takeaway

The Maximum update depth error is not a mysterious React bug. It is a safety net. It means your component is trying to re-render itself instead of waiting for an external signal. Break the habit of treating renders as events that should produce more state. Treat renders as pure consequences of state, not as causes. Keep state updates inside event handlers, callbacks, or effects with carefully chosen dependencies, and you will never see this error again.