Mémoire et performance JS/TS - 15 - Fuites mémoire en React

Les patterns React qui font fuir la mémoire : useEffect sans cleanup, listeners oublies, closures perimees, et comment les détecter.

  1. 01 Mémoire et performance JS/TS - 00 - Pourquoi la mémoire compte meme avec un garbage collector
  2. 02 Mémoire et performance JS/TS - 01 - Stack vs Heap
  3. 03 Mémoire et performance JS/TS - 02 - Le cycle de vie de la mémoire
  4. 04 Mémoire et performance JS/TS - 03 - Le garbage collector
  5. 05 Mémoire et performance JS/TS - 04 - V8 en profondeur
  6. 06 Mémoire et performance JS/TS - 05 - Les 6 fuites mémoire classiques
  7. 07 Mémoire et performance JS/TS - 06 - Closures et mémoire
  8. 08 Mémoire et performance JS/TS - 07 - WeakRef, WeakMap et WeakSet
  9. 09 Mémoire et performance JS/TS - 08 - FinalizationRegistry : savoir quand le GC passe
  10. 10 Mémoire et performance JS/TS - 09 - DevTools Memory : investiguer dans Chrome
  11. 11 Mémoire et performance JS/TS - 10 - Profiling mémoire en Node.js
  12. 12 Mémoire et performance JS/TS - 11 - Détecter et corriger les fuites mémoire
  13. 13 Mémoire et performance JS/TS - 12 - ArrayBuffer et TypedArrays
  14. 14 Mémoire et performance JS/TS - 13 - Workers et mémoire partagee
  15. 15 Mémoire et performance JS/TS - 14 - Streams et backpressure
  16. 16 Mémoire et performance JS/TS - 15 - Fuites mémoire en React
  17. 17 Mémoire et performance JS/TS - 16 - Serveurs Node.js et mémoire
  18. 18 Mémoire et performance JS/TS - 17 - Mémoire et Docker
  19. 19 Mémoire et performance JS/TS - 18 - Optimisations mémoire
  20. 20 Mémoire et performance JS/TS - 19 - Comparaison avec d'autres langages
  21. 21 Mémoire et performance JS/TS - 20 - Tester la mémoire
  22. 22 Mémoire et performance JS/TS - 21 - Glossaire

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


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

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.