Comment corriger l'erreur « Maximum update depth exceeded » dans React
Corrigez lerreur React maximum update depth exceeded avec des ajustements simples pour boucles de rendu, deps deffect, handlers et throttling.
« Maximum update depth exceeded » signifie que votre composant est bloqué dans une boucle de rendu infinie : une mise à jour d’état déclenche un nouveau rendu qui déclenche à son tour la même mise à jour d’état, et React interrompt le processus au-delà de 50 mises à jour imbriquées (sa limite NESTED_UPDATE_LIMIT) pour éviter que le navigateur ne se fige.
Si vous avez déjà vu un onglet se bloquer pendant que la même ligne rouge s’empile dans la console, vous savez à quel point ce mur de texte semble inutile au premier abord. La bonne nouvelle, c’est que la correction se résume presque toujours à une seule ligne, une fois que vous avez identifié le schéma en cause.
Ce guide couvre l’ensemble des causes possibles, y compris le cas des gestionnaires à haute fréquence, avec un avant/après prêt à copier-coller pour chacune, un tableau de correspondance des symptômes pour vous orienter rapidement, et l’outillage nécessaire pour localiser le setter fautif en moins d’une minute. La cause racine est identique dans les composants fonction et les composants classe : une boucle de mise à jour qui ne se stabilise jamais.
Points clés à retenir
- L’erreur correspond à une boucle de rendu infinie ; React l’interrompt lorsque le nombre de mises à jour imbriquées dépasse 50 (
NESTED_UPDATE_LIMITdans le code source du réconciliateur). onClick={handleClick()}appelle la fonction pendant le rendu et planifie une mise à jour d’état à chaque rendu. PassezonClick={handleClick}, etonClick={() => handleClick(id)}lorsque vous avez besoin de transmettre des arguments.- La correction la plus universelle est la mise à jour fonctionnelle (
setCount(prev => prev + 1)), qui vous permet de retirer cet état du tableau de dépendances de l’effet et rompt la boucle lecture-puis-écriture. useCallbackstabilise l’identité d’une fonction, pas sa fréquence d’exécution : il ne corrigera donc pas les boucles causées par des gestionnaires à haute fréquence commeonScrollouonDragMovede dnd-kit ; limitez plutôt la mise à jour d’état avec un throttle ou un debounce.- L’erreur
#185des composants classe est levée aussi bien en développement qu’en production, tandis que la varianteuseEffectn’est qu’un avertissement de développement. En production, cette boucle s’exécute sans exception ni signal dans la console.
Tableau de correspondance symptôme → cause → correction
Repérez le symptôme correspondant à ce que vous observez, puis rendez-vous à la correction adéquate.
| Symptôme | Cause | Correction |
|---|---|---|
| Boucle sans aucun clic, démarrant au montage | onClick={fn()} appelle le setter pendant le rendu | Passer onClick={fn} |
setState au niveau racine du composant | Mise à jour d’état dans le chemin de rendu | La déplacer dans un gestionnaire ou un effet |
| L’effet se déclenche à chaque rendu | Dépendances manquantes/incorrectes, ou objet/tableau inline dans les dépendances | Corriger les dépendances + useMemo/useCallback |
| L’effet lit et écrit le même état | L’état figure dans ses propres dépendances | Mise à jour fonctionnelle ; retirer la dépendance |
| Boucle uniquement pendant un glisser-déposer/défilement/redimensionnement | setState à chaque événement à haute fréquence | Throttle/debounce sur la mise à jour |
| Le parent et l’enfant s’écrasent mutuellement | Synchronisation bidirectionnelle de l’état | Rendre le flux unidirectionnel |
Discover how at OpenReplay.com.
Corrections 1 et 2 : références des gestionnaires et setState dans le chemin de rendu
Les deux gains les plus rapides concernent la façon dont vous connectez les gestionnaires et l’endroit où vous appelez le setter. onClick={handleClick()} appelle la fonction pendant le rendu et planifie une mise à jour d’état à chaque rendu ; vous voulez presque toujours onClick={handleClick}, et onClick={() => handleClick(id)} lorsque vous devez transmettre des 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>
La même boucle se produit lorsque vous appelez un setter directement dans le corps du composant. Les mises à jour d’état ont leur place dans un gestionnaire d’événement ou un effet, jamais dans le chemin de rendu.
// 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);
Corrections 3, 4 et 5 : boucles de dépendances d’effets
La plupart des boucles d’effets proviennent de dépendances dont l’identité change, ou d’un effet qui écrit l’état qu’il lit. Un littéral objet ou tableau écrit en ligne dans un tableau de dépendances obtient une nouvelle identité à chaque rendu, si bien que l’effet se réexécute à chaque rendu ; encapsulez-le dans useMemo (objets/tableaux) ou useCallback (fonctions) afin que sa référence reste 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' }), []);
La correction la plus universelle est la mise à jour fonctionnelle. Lorsqu’un effet lit et écrit le même état, setCount(prev => prev + 1) lit la valeur précédente depuis l’argument de la fonction de mise à jour plutôt que depuis la fermeture (closure), ce qui vous permet de retirer cet état du tableau de dépendances et rompt la boucle.
// 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); }, []);
Pour une fonction dont dépend un effet, encapsulez-la dans useCallback avec les bonnes dépendances, ou déplacez-la à l’intérieur de l’effet : une fonction déclarée à l’intérieur de l’effet est créée une fois par exécution et n’a pas besoin de figurer parmi les dépendances.
Correction 6 : limiter la fréquence des gestionnaires à haute fréquence
useCallback stabilise l’identité d’une fonction d’un rendu à l’autre, mais ne modifie pas la fréquence à laquelle elle s’exécute : il ne corrigera donc pas une boucle infinie provoquée par un gestionnaire à haute fréquence comme onScroll, onMouseMove, onResize ou onDragMove de dnd-kit. Ces événements se déclenchent des dizaines de fois par seconde, et chaque setState planifie un nouveau rendu. Appliquez plutôt un throttle ou un debounce sur la mise à jour d’état.
// 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);
La fonction throttle de lodash plafonne le nombre de déclenchements de la fonction encapsulée sur une fenêtre de temps donnée ; requestAnimationFrame en natif ou un debounce font également l’affaire. L’objectif est le contrôle de la fréquence, pas la stabilité de la référence.
Correction 7 : boucles de synchronisation parent-enfant
La propagation bidirectionnelle de l’état (un effet enfant qui appelle le setter du parent, lequel provoque un nouveau rendu de l’enfant, qui déclenche à nouveau l’effet) constitue une autre boucle classique. Remontez l’état vers un unique propriétaire et maintenez un flux de données unidirectionnel, ou transformez la valeur dans le parent plutôt que de la resynchroniser via un effet.
Localiser le problème rapidement (et le cas des composants classe)
Procédez de haut en bas : lisez la trace d’appel jusqu’au setter nommé en tête de la boucle, puis ouvrez le Profiler des React DevTools pour repérer le composant qui se re-rend sans interruption. Ajoutez why-did-you-render (testé avec React 19, uniquement en développement, et non testé avec React Compiler) pour identifier la prop ou l’état dont l’identité a changé. Détectez ces problèmes dès le linting : depuis eslint-plugin-react-hooks 7.x, le plugin embarque des règles dédiées set-state-in-render et set-state-in-effect qui interceptent les deux déclencheurs les plus courants avant même l’exécution de l’application. Les deux figurent dans le preset recommended par défaut, si bien qu’une simple mise à jour du plugin suffit à les activer ; recommended-latest ne fait qu’ajouter par-dessus les règles expérimentales du compilateur. set-state-in-render se déclenche lorsqu’un composant modifie l’état pendant le rendu sans aucune condition protégeant l’appel, ce qui correspond précisément au schéma qui dégénère en boucle.
L’erreur a la même cause racine dans les composants fonction et les composants classe ; dans les composants classe, elle signifie généralement un appel à setState pendant le rendu ou de façon inconditionnelle à l’intérieur de componentDidUpdate. Surveillez également l’environnement : l’erreur #185 des composants classe est levée aussi bien en développement qu’en production, tandis que la variante useEffect n’est qu’un avertissement de développement. Dans le code source du réconciliateur, la vérification des mises à jour passives imbriquées se trouve à l’intérieur d’une garde réservée au développement et écrit dans la console au lieu de lever une exception : un build de production exécute donc cette boucle d’effet sans exception, sans error boundary et sans signal dans la console, et elle ne se manifeste que par un onglet figé.
C’est précisément cet écart en production qui rend le débogage de la boucle coûteux. La console affiche l’erreur mais pas quelle interaction l’a provoquée, et pour les boucles d’effets, elle peut n’afficher aucune erreur du tout. Les outils de session replay comme OpenReplay capturent l’erreur console, lorsqu’elle est présente, aux côtés de la séquence d’actions utilisateur qui l’a précédée : une simple trace d’appel (ou un blocage silencieux) devient ainsi un cas reproductible, clic par clic, que vous pouvez rejouer en appliquant les corrections ci-dessus.
Une fois votre symptôme rapproché d’une ligne du tableau, le changement se limite généralement à une seule ligne : une référence à la place d’un appel, un useMemo autour d’un objet, ou une mise à jour fonctionnelle qui permet de se passer d’une dépendance. Mettez en place exhaustive-deps ainsi que les nouvelles règles set-state-in-* pour que la prochaine boucle fasse échouer votre étape de lint plutôt que les navigateurs de vos utilisateurs.
FAQ
Quelle est la différence entre la variante useEffect de cette erreur et l'erreur React #185 ?
Il s'agit de deux messages distincts au comportement d'exécution différent. L'erreur #185 est la formulation propre aux composants classe, mentionnant setState à l'intérieur de componentWillUpdate ou componentDidUpdate, et elle est levée aussi bien en développement qu'en production. La variante useEffect est un avertissement distinct, émis uniquement en développement par le code source du réconciliateur et protégé par une garde réservée au développement : en production, la boucle d'effet s'exécute donc sans exception, sans error boundary et sans aucun signal dans la console.
Pourquoi l'ajout de useCallback n'arrête-t-il pas ma boucle infinie pendant un glisser-déposer ou un défilement ?
Parce que useCallback stabilise l'identité d'une fonction d'un rendu à l'autre mais ne modifie pas la fréquence à laquelle cette fonction s'exécute. Un gestionnaire à haute fréquence comme onScroll, onMouseMove ou onDragMove de dnd-kit se déclenche des dizaines de fois par seconde, et chaque setState planifie un nouveau rendu, que la référence de la fonction soit mémoïsée ou non. La solution consiste à contrôler la fréquence : appliquez un throttle ou un debounce sur la mise à jour d'état elle-même, à l'aide de throttle de lodash, d'un debounce ou de requestAnimationFrame.
La forme de mise à jour fonctionnelle me permet-elle toujours de retirer un état du tableau de dépendances d'un effet ?
Uniquement lorsque le seul besoin de l'effet vis-à-vis de cet état consiste à calculer la valeur suivante. Écrire setCount(prev => prev + 1) lit la valeur précédente depuis l'argument de la fonction de mise à jour plutôt que depuis la fermeture, si bien que count peut quitter le tableau de dépendances et que la boucle lecture-puis-écriture est rompue. Si l'effet lit également cet état pour d'autres traitements, comme un branchement conditionnel ou sa transmission à une autre fonction, vous devez le conserver comme dépendance et rompre la boucle autrement.
Pourquoi l'erreur apparaît-elle en développement alors que mon build de production se fige silencieusement ?
Parce que la garde sur les mises à jour passives imbriquées de la variante useEffect est encapsulée dans une vérification réservée au développement au sein du réconciliateur React : elle n'émet donc un avertissement dans la console qu'en développement. Dans un build de production, cette garde ne s'exécute pas, ce qui signifie que la même boucle d'effet s'exécute sans exception levée ni message dans la console, et ne se manifeste que par un onglet figé ou des rendus incontrôlés. L'erreur #185 des composants classe est différente et est levée dans les deux environnements.
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