L'onglet du navigateur de votre utilisateur se fige après trente minutes. L'interface utilisateur (UI) saccade. Puis le navigateur plante avec une erreur de type "out-of-memory".
Vous examinez le code du composant ligne par ligne et ne voyez rien d'anormal. C'est là toute la frustration des fuites de mémoire dans React. Le bug ne réside pas dans votre syntaxe JSX ou votre logique de hooks. Il se niche dans l'écart entre votre composant et le garbage collector du navigateur. Un écouteur d'événement égaré ou une closure à longue durée de vie empêche le collecteur de récupérer la mémoire. Vous démontez un composant, mais une seule référence persistante maintient l'intégralité de l'arbre en vie dans le heap. Vous ne pouvez pas trouver ces fuites en vous contentant de lire le code. Le problème reste caché sous la surface, invisible à l'œil nu et silencieux lors des tests unitaires.
Pourquoi les fuites se cachent à la vue de tous
Les moteurs JavaScript comme V8 gèrent la mémoire automatiquement. Lorsqu'aucun chemin de référence n'existe entre la racine et un objet, le moteur marque cet objet comme "garbage" (déchet) et récupère l'espace. Ce processus fonctionne parfaitement jusqu'à ce qu'une référence cachée survive plus longtemps que prévu.
Dans React, le danger apparaît souvent à la frontière entre les composants et le DOM. Vous pourriez attacher un écouteur resize à l'objet window à l'intérieur d'une modale, ou vous abonner à un WebSocket dans un widget de tableau de bord. Lorsque l'utilisateur ferme la modale ou change de page, le composant est démonté. Si l'abonnement survit, le moteur voit une référence valide allant de l'objet global window jusqu'à votre handler, puis de votre handler vers la closure du composant. Le composant, ses props, son état et toute sa sous-arborescence de nœuds DOM restent bloqués en mémoire. Au fil de centaines d'interactions, ces objets bloqués s'accumulent. L'utilisation de la mémoire grimpe selon un motif en dents de scie qui ne redescend jamais complètement.
Le problème des tableaux de bord d'entreprise
Cela se produit principalement dans les tableaux de bord d'entreprise où les utilisateurs restent sur une même page pendant des heures. Pensez aux panneaux de surveillance, aux vues analytiques ou aux systèmes de ticketing. Un utilisateur ouvre une modale de détails, filtre un large jeu de données ou change d'onglet au sein d'une application monopage (SPA). Chaque interaction individuelle semble normale. Cependant, avec le temps, les nœuds orphelins et les écouteurs détachés s'accumulent. L'application ralentit, non pas à cause d'un rendu coûteux unique, mais parce que le heap devient assez volumineux pour déclencher des pauses de garbage collection fréquentes et coûteuses.
Arrêtez de deviner ce qui se passe dans vos hooks useEffect. La seule façon de savoir si une fuite existe est de mesurer directement le heap. Chrome DevTools vous offre cette visibilité.
Chasser les fuites avec Chrome DevTools
Vous avez besoin d'une séquence reproductible et de quelques minutes d'attention soutenue. Ouvrez votre application dans Chrome, lancez les DevTools et accédez à l'onglet Memory.
Enregistrez une référence (baseline). Sélectionnez Heap snapshot et cliquez sur Take snapshot. Cela capture chaque objet vivant actuellement dans le heap JavaScript et vous montre la mémoire de départ. Faites cela une fois que la page est arrivée à son état d'inactivité initial, et non pendant le chargement initial, afin de ne mesurer que la croissance causée par les actions de l'utilisateur.
Déclenchez l'action. Effectuez l'interaction UI exacte que vous soupçonnez d'être la cause de la fuite. Ouvrez et fermez une modale, basculez un graphique complexe, ou changez de route puis revenez en arrière. Une fois terminé, remettez l'application dans son état visuel d'origine. Cette étape est cruciale. Vous voulez que l'UI ressemble exactement à ce qu'elle était lors de la référence initiale. Si le heap a augmenté alors que l'UI semble vide, vous avez une preuve solide d'une fuite.
Forcez le garbage collection. Cliquez sur l'icône de la corbeille dans l'onglet Memory. Cela déclenche un cycle de GC complet et nettoie les objets temporaires qui ont légitimement survécu jusqu'à la collecte suivante. Ce qui reste constitue les véritables fuites : des objets qui auraient dû être collectés mais qui ont été maintenus en vie par des références accidentelles.
Prenez un second snapshot. Cliquez à nouveau sur Take snapshot. Vous disposez maintenant de deux photographies du heap prises dans les mêmes conditions d'UI.
Comparez les résultats. Changez la vue de Summary à Comparison. Définissez la référence sur votre premier snapshot et le snapshot à comparer sur le second. La vue Comparison liste chaque catégorie d'objet et vous montre le delta, c'est-à-dire le changement net du nombre d'objets entre les deux captures.
Triez par Delta. Recherchez les catégories qui ont augmenté de manière significative. Concentrez-vous particulièrement sur les HTMLElement détachés (Detached) et les nœuds React fiber. Un élément HTML détaché est un nœud DOM qui n'est plus attaché à l'arbre du document actif, mais qu'une référence JavaScript maintient encore. Ce sont des preuves irréfutables. Ils ne devraient pas exister après la fermeture d'une modale ou le démontage d'un composant.
Lire le chemin de rétention (Retaining Path)
When you select a detached element in the snapshot, Chrome shows the retaining path in the bottom panel. This path is a chain of references from the root down to the selected object. Follow it carefully. You will often find an event listener, an IntersectionObserver, a setInterval ID, or a closure pointing to a specific line in your component.
Look for names you recognize. If you see a listener attached to window with a function name from your codebase, you have found the anchor. The object holding that listener is keeping your entire component alive. Sometimes the chain runs through a third-party library. In those cases, check whether the library expects an explicit teardown call that you forgot to invoke in a cleanup function.
Fixing the Root Causes
Once you identify the retaining path, the fix is usually mechanical but requires discipline across the team.
Use cleanup functions. Always return a cleanup function in useEffect when you add listeners to the window or document. If your effect subscribes to a resize event, remove that subscription before the component unmounts. The cleanup runs when React tears down the component, giving you a guaranteed hook to sever external connections.
Stabilize references. Wrap your handlers in useCallback. This ensures you pass the exact same function reference to removeEventListener that you originally passed to addEventListener. If you register an inline function like window.addEventListener('resize', () => { ... }) and later try to remove it with another inline function, the references will not match. The listener stays attached. The closure inside it keeps your component state alive. useCallback with a stable dependency array prevents this identity mismatch.
Sever global links. Remember that the browser's event target objects, like window and document, live for the lifetime of the page. Any reference from them into your component acts as a global anchor. Removing the listener breaks that anchor and lets the V8 engine sweep away the component state and DOM nodes during the next garbage collection cycle.
Consider a modal that tracks window width. Without cleanup, each time the user opens the modal, a new listener attaches. The old ones never detach because the component instances they belong to are gone, yet the functions themselves were anonymous and lost. Using a named handler wrapped in useCallback, plus a cleanup function that calls removeEventListener, closes the loop cleanly.
A Real Takeaway
Memory leaks in React rarely announce themselves with a clear error message. They announce themselves with a tab that grows heavier the longer it stays open. Do not waste time speculating about which hook is guilty. Open the Memory tab, force garbage collection, and compare snapshots. Let the heap profiler show you the exact retaining path. Then write the cleanup function, stabilize the callback reference, and cut the global link. Your users will not notice the fix directly, but they will notice that the dashboard still runs smoothly at the end of the day.
