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.

Considérez ce modèle défectueux :

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

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

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

Comme il n'y a pas de tableau de dépendances, cet effet s'exécute après chaque rendu. Il définit user, ce qui déclenche un rendu. Après ce rendu, l'effet s'exécute à nouveau et définit à nouveau user. React détecte la spirale et renvoie l'erreur.

La solution consiste à indiquer à React quand l'effet doit réellement s'exécuter en fournissant un tableau de dépendances approprié. Si l'effet ne doit s'exécuter qu'une seule fois au montage, passez un tableau vide :

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

Si l'effet dépend d'une prop ou d'un état, incluez uniquement cette variable dans le tableau. Soyez toutefois prudent. Inclure une variable qui change à chaque rendu ne fera que recréer la même boucle par une autre porte. Par exemple, si vous incluez un littéral d'objet dans les dépendances et que cet objet est recréé à chaque rendu du parent, l'effet s'exécutera indéfiniment. Dans ces cas, vous devrez peut-être déplacer la création de l'objet en dehors du composant ou le mémoïser.

Étapes de débogage pratiques

Lorsque vous rencontrez cette erreur, la trace de la pile (stack trace) peut sembler accablante car React a déjà répété le cycle des dizaines de fois. Commencez par lire le haut de la trace pour trouver quel composant est nommé de manière répétée. Ensuite, recherchez les fonctions de mise à jour d'état (state setters) à ces trois endroits :

  1. Le corps principal du composant, en dehors de tout gestionnaire (handler) ou hook.
  2. Les attributs d'événement JSX où vous auriez pu écrire handler() au lieu de handler.
  3. Les hooks useEffect qui n'ont pas de tableau de dépendances ou qui dépendent de références instables.

Commentez temporairement chaque setter d'état jusqu'à ce que l'erreur s'arrête. Cela vous indiquera exactement quelle mise à jour est la coupable. Si le setter se trouve à l'intérieur d'un effet, demandez-vous si vous avez réellement besoin d'un état à cet endroit. Parfois, les développeurs définissent un état local à partir de props à l'intérieur d'un effet alors qu'ils pourraient simplement utiliser la prop directement dans le JSX.

Ce qu'il faut vraiment retenir

L'erreur « Maximum update depth exceeded » n'est pas un bug mystérieux de React. C'est un filet de sécurité. Cela signifie que votre composant tente de se re-rendre lui-même au lieu d'attendre un signal externe. Prenez l'habitude de ne plus traiter les rendus comme des événements qui devraient produire plus d'états. Considérez les rendus comme de pures conséquences de l'état, et non comme des causes. Gardez les mises à jour d'état à l'intérieur des gestionnaires d'événements, des callbacks ou des effets avec des dépendances soigneusement choisies, et vous ne verrez plus jamais cette erreur.