15 - Fuites mémoire en React
Ce que tu vas apprendre
- Pourquoi useEffect sans cleanup est la première source de fuites en React
- Comment les closures perimees retiennent de la mémoire invisible
- Quand React.memo aide vraiment (et quand ca empire les choses)
- Utiliser le React DevTools Profiler pour traquer les re-renders
Prerequisites
- Connaitre les hooks React (useState, useEffect)
- Avoir lu les 6 fuites classiques pour le contexte général
Mon composant Dashboard qui pesait 800 Mo
Sur un projet interne, on avait un dashboard avec des graphiques temps réel. Des WebSocket poussaient des donnees toutes les secondes. Le composant montait, recevait les donnees, les stockait dans un useState. Jusque-la, normal.
Le problème : quand tu naviguais vers une autre page, le composant se demontait, mais le listener WebSocket restait actif. Il continuait a recevoir des donnees et a appeler le setter du state. React loguait un warning "Can't perform a React state update on an unmounted component", mais la mémoire montait quand meme. 10 minutes de navigation = 800 Mo.
useEffect sans cleanup : la fuite numero un
typescript// La fuite
useEffect(() => {
const ws = new WebSocket("wss://api.example.com/stream");
ws.onmessage = (event) => {
setData(JSON.parse(event.data));
};
// Pas de return. Pas de cleanup. Le WebSocket survit au composant.
}, []);
// Le fix
useEffect(() => {
const ws = new WebSocket("wss://api.example.com/stream");
ws.onmessage = (event) => {
setData(JSON.parse(event.data));
};
return () => {
ws.close();
};
}, []);
La regle est simple : si tu ouvres quelque chose dans useEffect, tu le fermes dans le return. Toujours. Meme si tu penses que le composant ne sera jamais demonte.
Event listeners dans les effects
typescript// Fuite classique
useEffect(() => {
window.addEventListener("resize", handleResize);
// Oups
}, []);
// Fix
useEffect(() => {
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
Un détail qui tue : si handleResize est recree a chaque render (parce que c'est une arrow function dans le composant), le removeEventListener ne retire rien. Il faut que la référencé soit stable. Soit tu le définis en dehors du composant, soit tu utilises useCallback.
setInterval : le piège silencieux
typescriptuseEffect(() => {
const id = setInterval(() => {
setCount((c) => c + 1);
}, 1000);
return () => clearInterval(id);
}, []);
J'ai vu des projets avec 30 setInterval actifs en parallèle parce que le composant se montait et demontait dans une liste dynamique. Chaque intervalle gardait une closure sur le state du composant. 30 closures, 30 copies du state.
AbortController pour les fetch
typescriptuseEffect(() => {
const controller = new AbortController();
fetch("/api/data", { signal: controller.signal })
.then((res) => res.json())
.then(setData)
.catch((err) => {
if (err.name !== "AbortError") throw err;
});
return () => controller.abort();
}, [query]);
Sans AbortController, si tu navigues vite entre les pages, tu peux avoir 15 fetch en cours qui vont tous essayer de mettre à jour un state qui n'existe plus. Le AbortController annule proprement la requête quand le composant se demonte ou quand la dépendance change.
Closures perimees et vieux state
typescriptuseEffect(() => {
const id = setInterval(() => {
// Ce count est toujours 0. La closure capture la valeur initiale.
console.log(count);
}, 1000);
return () => clearInterval(id);
}, []); // Le tableau vide fige la closure
// Fix : utiliser une ref ou le pattern fonctionnel
useEffect(() => {
const id = setInterval(() => {
setCount((prev) => prev + 1); // Toujours la valeur actuelle
}, 1000);
return () => clearInterval(id);
}, []);
Les closures perimees ne causent pas directement de fuite, mais elles retiennent l'ancien state en mémoire. Si le state contient des donnees volumineuses (un tableau de 10 000 lignes par exemple), chaque closure perimee retient une copie.
Listes longues sans virtualisation
Rendre 50 000 éléments DOM, ca consomme de la mémoire. Beaucoup. Chaque noeud DOM pese entre 1 et 4 Ko selon sa complexité. 50 000 noeuds = 50 a 200 Mo rien que pour le DOM.
typescriptimport { FixedSizeList } from "react-window";
function BigList({ items }: { items: string[] }) {
return (
<FixedSizeList height={600} width={400} itemCount={items.length} itemSize={35}>
{({ index, style }) => <div style={style}>{items[index]}</div>}
</FixedSizeList>
);
}
react-window ne rend que les éléments visibles. 50 000 items, 20 visibles = 20 noeuds DOM au lieu de 50 000. Sur paltemps.fr, j'ai utilise cette technique pour la liste des stations meteo. La page est passee de 3 secondes de chargement a instantane.
React DevTools Profiler
L'onglet Profiler de React DevTools enregistre les re-renders. Tu lances l'enregistrement, tu interagis avec l'appli, tu stoppes, et tu vois chaque render avec sa duree et sa raison.
Ce que tu cherches :
- Des composants qui re-rendent sans raison visible
- Des renders qui prennent plus de 16ms (seuil pour 60 fps)
- Des composants qui re-rendent en cascade (un parent qui force tous ses enfants)
Le "Why did this render?" te dit exactement quelle prop ou quel state a change. Active-le dans les settings du Profiler.
memo, useMemo : quand ca aide et quand ca nuit
typescript// Utile : composant lourd avec des props stables
const ExpensiveChart = React.memo(function Chart({ data }: { data: Point[] }) {
// Rendu couteux
return <canvas />;
});
// Inutile : composant leger qui re-rend vite de toute facon
const Label = React.memo(function Label({ text }: { text: string }) {
return <span>{text}</span>;
});
React.memo a un coût. A chaque render du parent, React compare les anciennes et nouvelles props (shallow comparison). Si le composant est simple et que le re-render est rapide, le coût de la comparaison dépassé le gain. Tu ralentis l'appli en essayant de l'optimiser.
useMemo meme principe : si le calcul est trivial, le overhead de useMemo (stocker la valeur précédente, comparer les deps) est plus eleve que de refaire le calcul.
Ma regle : mesure d'abord avec le Profiler. Optimise ensuite. Pas l'inverse.
Résumé
- Chaque useEffect qui ouvre une ressource doit avoir un cleanup dans le return.
- AbortController est le mecanisme standard pour annuler les fetch.
- Les closures perimees retiennent le vieux state en mémoire.
- react-window virtualise les listes longues et divise la mémoire DOM par 1000x.
- React.memo n'est pas gratuit : mesure avant d'optimiser.
Precedent : Streams et backpressure Suivant : Serveurs Node.js et mémoire