Votre application fonctionne correctement pendant dix minutes. Puis le défilement devient saccadé. Après une demi-heure, l'onglet atteint un gigaoctet. Finalement, la page plante avec une erreur de dépassement de mémoire (out-of-memory) et vous n'avez aucune trace de pile (stack trace) pour expliquer cela.
Il ne s'agit pas d'un problème de performance de rendu. Le Profiler de React DevTools semblera calme car le problème ne réside pas dans la fréquence de redessinage des composants. Le problème est ce qui reste en vie après leur démontage (unmount). Une référence égarée quelque part dans le tas (heap) JavaScript bloque tout un arbre de nœuds DOM, de fermetures (closures) et d'états. Le navigateur ne peut rien récupérer, la mémoire grimpe donc jusqu'à l'effondrement du processus.
Lire votre code source ne révélera pas la fuite. Le bug se niche dans l'écart entre ce que vous pensez avoir démonté et ce que le ramasse-miettes (garbage collector) voit réellement. V8 ne libère que les objets qui n'ont aucun chemin de rétention (retaining path). Si un écouteur d'événement parasite, un observateur non nettoyé ou une fermeture (closure) de longue durée conserve ne serait-ce qu'un pointeur vers une fiber ou un nœud DOM, toute la sous-arborescence du composant survit. Vous démontez une modale, mais ses nœuds détachés restent en mémoire car un écouteur sur window pointe toujours vers un gestionnaire (handler) défini à l'intérieur de cette modale.
Pour prouver l'origine de la fuite, vous devez regarder le tas (heap), pas l'éditeur.
Pourquoi le tas (heap) dit la vérité
Chrome DevTools vous offre une fenêtre directe sur ce que voit le ramasse-miettes. L'onglet Memory peut enregistrer des instantanés du tas (heap snapshots) : des inventaires complets de chaque objet, nœud DOM et fermeture actuellement détenus dans la mémoire JavaScript. En comparant deux instantanés — l'un avant une fuite suspectée et l'autre après — vous pouvez isoler exactement quels objets n'ont pas été supprimés.
Ce n'est pas de la théorie abstraite. Un seul composant React en fuite peut conserver des milliers d'objets HTMLElement détachés. Ces objets ne sont plus attachés au document visible, mais des références JavaScript empêchent leur collecte. Ils apparaissent dans la vue de comparaison avec le nom de constructeur Detached HTMLElement. Lorsque vous les voyez se multiplier, vous avez trouvé votre fuite.
Le flux de travail (workflow) de Chrome DevTools
Partez sur une base saine. Fermez les onglets de navigateur non liés, désactivez les extensions inutiles et laissez votre application se stabiliser dans un état constant. Ouvrez Chrome DevTools, passez sur l'onglet Memory et sélectionnez Heap snapshot. Cliquez sur Take snapshot. Cette référence de base capture votre empreinte mémoire initiale.
Effectuez maintenant l'action utilisateur exacte que vous suspectez. Ouvrez et fermez cette modale lourde. Montez et démontez le widget. Naviguez vers la route et revenez. Une fois que l'interface utilisateur est revenue à son état visuel d'origine, cliquez sur l'icône de la corbeille dans l'onglet Memory. Cela force un passage global du ramasse-miettes. Les objets temporaires du cycle de rendu devraient disparaître. Tout ce qui subsiste est un candidat réel pour une fuite.
Cliquez à nouveau sur Take snapshot. Vous avez maintenant deux photographies de la mémoire. Changez la vue de Summary à Comparison. Définissez la portée de la comparaison sur le premier instantané. L'outil ne vous montrera que ce qui a changé entre les deux captures, éliminant le bruit de l'exécution (runtime).
Triez par Delta. Recherchez les nombres d'objets qui ont augmenté. Portez une attention particulière aux constructeurs comme Detached HTMLElement, Array, Function, ou même des instances de classes nommées provenant de votre propre code. Un delta croissant signifie que des objets ont été créés pendant votre action et n'ont pas été collectés par la suite.
Tracer le chemin de rétention (retaining path)
Lorsque vous repérez un élément en fuite, sélectionnez-le. Le panneau inférieur affiche le chemin de rétention (retaining path) : une chaîne de références qui explique pourquoi cet objet est toujours en vie. La chaîne peut partir d'un div détaché, remonter à travers les propriétés internes de React, entrer dans une fermeture (closure), et finalement aboutir à un écouteur d'événement enregistré à l'intérieur de l'un de vos composants. Ce dernier maillon de la chaîne est votre numéro de ligne.
C'est ici que vous passez du diagnostic à la cause profonde. Si le chemin de rétention se termine par window.addEventListener, vous savez qu'un écouteur global retient votre composant en otage. S'il se termine par une instance d' IntersectionObserver, vous savez qu'un observateur surveille toujours un nœud qui aurait dû être collecté par le ramasse-miettes.
Coupables courants dans React
Les fuites de mémoire dans React se répartissent généralement en trois modèles.
Écouteurs globaux orphelins. Un useEffect s'accroche à window ou document pour suivre la position du défilement, les pressions de touches ou les événements de redimensionnement. Si l'effet ne retourne pas une fonction de nettoyage (cleanup function) qui appelle removeEventListener, l'écouteur survit pendant toute la durée de vie de la page. Comme l'écouteur est une fermeture (closure), il maintient tout le scope du composant en vie longtemps après que React a démonté le composant.
Uncleared observers. IntersectionObserver and ResizeObserver are powerful, but they create native references outside React’s control. If you instantiate an observer inside a component and forget to call disconnect() in the cleanup phase, the observer holds the target DOM node, and the DOM node holds React fibers, props, and state.
Closure traps. When you define a function inside a component and pass it to a third-party library, a global cache, or even setTimeout, that function closes over every variable in its lexical scope. If the external owner keeps the function around, it keeps your whole component scope around with it.
Cleanup Patterns That Actually Work
Fixing a leak means severing every retaining path you found in the snapshot.
Always return a cleanup function from useEffect. If you add a listener in the effect, remove it there.
Use useCallback for any handler you attach to the DOM or window. Without it, every render creates a new function reference. If you call addEventListener with one reference and later call removeEventListener with a different one, the removal fails silently. The original listener stays on window forever. useCallback keeps the reference stable so that add and remove match exactly.
Handle observers with the same discipline. Store the observer instance in a ref or local variable inside the effect. In the cleanup function, call observer.disconnect(). Do not assume unmounting the component kills the observer. It does not.
If your component publishes anything to a global namespace or a singleton service, delete those references on unmount. The V8 engine can only reclaim memory when an object is truly unreachable. Leaving a hook on window or an entry in a module-level Map creates an invisible bridge that keeps the heap growing.
The Real Takeaway
Memory leaks do not crash your app immediately. They accumulate one detached node at a time during long user sessions. The fix is not a library upgrade or a compiler flag. It is the habit of proving your cleanup logic with heap snapshots.
Take a baseline, trigger the suspect flow, force garbage collection, and compare. If the delta shows growth, inspect the retaining path, find the listener or observer that should not exist, and cut the reference. Run the test again. When the delta stays flat, you have actually solved it. Your application will stay responsive, and your users will not lose their work to a frozen browser tab.
