Twoja aplikacja działa poprawnie przez dziesięć minut. Potem przewijanie staje się ociężałe. Po pół godzinie karta zajmuje gigabajt. W końcu strona pada z błędem braku pamięci (out-of-memory), a Ty nie masz żadnego stack trace'a, który mógłby to wyjaśnić.

To nie jest problem z wydajnością renderowania. React DevTools Profiler będzie wydawał się spokojny, ponieważ problemem nie jest to, jak często komponenty są ponownie rysowane. Problemem jest to, co pozostaje aktywne po ich odmontowaniu (unmount). Błądząca referencja gdzieś w stercie JavaScript (JavaScript heap) blokuje całe drzewo węzłów DOM, domknięć (closures) i stanu. Przeglądarka nie może odzyskać żadnego z tych zasobów, więc zużycie pamięci rośnie, aż proces padnie.

Czytanie kodu źródłowego nie ujawni wycieku. Błąd kryje się w luce między tym, co uważasz za odmontowane, a tym, co faktycznie widzi garbage collector. V8 zwalnia tylko te obiekty, które mają zero ścieżek utrzymujących (retaining paths). Jeśli niekontrolowany listener zdarzeń, nieoczyszczony observer lub długożyjące domknięcie trzyma choćby jeden wskaźnik do fibra lub węzła DOM, całe poddrzewo komponentu przetrwa. Odmontowujesz modal, ale jego odłączone (detached) węzły pozostają w pamięci, ponieważ listener na window wciąż wskazuje na handler zdefiniowany wewnątrz tego modala.

Aby udowodnić, gdzie znajduje się wyciek, musisz spojrzeć na stertę, a nie na edytor.

Dlaczego sterta mówi prawdę

Chrome DevTools daje Ci bezpośredni wgląd w to, co widzi garbage collector. Karta Memory pozwala na rejestrowanie zrzutów sterty (heap snapshots): kompletnych inwentaryzacji każdego obiektu, węzła DOM i domknięcia aktualnie znajdującego się w pamięci JavaScript. Porównując dwa zrzuty — jeden przed podejrzewanym wyciekiem, a drugi po — możesz precyzyjnie wyizolować obiekty, które nie zostały usunięte.

To nie jest abstrakcyjna teoria. Pojedynczy wyciekający komponent React może zatrzymywać tysiące odłączonych obiektów HTMLElement. Obiekty te nie są już podpięte do widocznego dokumentu, ale referencje JavaScript uniemożliwiają ich zebranie. Pojawiają się one w widoku porównania z nazwą konstruktora Detached HTMLElement. Gdy widzisz, jak ich liczba rośnie, znalazłeś swój wyciek.

Workflow w Chrome DevTools

Zacznij od czystej karty. Zamknij niepowiązane karty przeglądarki, wyłącz niepotrzebne rozszerzenia i pozwól aplikacji osiągnąć stan stabilny. Otwórz Chrome DevTools, przejdź do karty Memory i wybierz Heap snapshot. Kliknij Take snapshot. Ten punkt odniesienia (baseline) rejestruje początkowe zużycie pamięci.

Teraz wykonaj dokładnie tę czynność użytkownika, która budzi Twoje podejrzenia. Otwórz i zamknij ten ciężki modal. Zamontuj i odmontuj widget. Przejdź do danej trasy i wróć. Gdy interfejs użytkownika (UI) wróci do swojego pierwotnego stanu wizualnego, kliknij ikonę kosza na karcie Memory. Wymusza to globalne przebieganie procesu garbage collection. Tymczasowe obiekty z cyklu renderowania powinny zniknąć. Wszystko, co pozostanie, jest realnym kandydatem na wyciek.

Kliknij ponownie Take snapshot. Masz teraz dwa „zdjęcia” pamięci. Zmień widok z Summary na Comparison. Ustaw zakres porównania (comparison scope) na pierwszy zrzut. Narzędzie pokaże Ci tylko to, co zmieniło się między dwoma przechwyceniami, eliminując szum środowiska uruchomieniowego (runtime).

Posortuj według Delta. Szukaj zwiększającej się liczby obiektów. Zwróć szczególną uwagę na konstruktory takie jak Detached HTMLElement, Array, Function lub nawet nazwane instancje klas z Twojego własnego kodu. Rosnąca delta oznacza, że obiekty zostały utworzone podczas Twojej czynności i nie zostały po niej zebrane.

Śledzenie ścieżki utrzymującej (Retaining Path)

Gdy znajdziesz wyciekający element, wybierz go. Dolny panel wyświetli ścieżkę utrzymującą (retaining path): łańcuch referencji wyjaśniający, dlaczego ten obiekt wciąż żyje. Łańcuch może prowadzić od odłączonego div, przez wewnętrzne właściwości React, do domknięcia, aż w końcu trafi na listener zdarzeń zarejestrowany wewnątrz jednego z Twoich komponentów. To ostatnie ogniwo w łańcuchu to numer Twojej linii kodu.

To moment, w którym przechodzisz od diagnozy do przyczyny źródłowej. Jeśli ścieżka utrzymująca kończy się na window.addEventListener, wiesz, że globalny listener trzyma Twój komponent jako zakładnika. Jeśli kończy się na instancji IntersectionObserver, wiesz, że observer wciąż obserwuje węzeł, który powinien zostać usunięty przez garbage collector.

Typowe przyczyny w React

Wycieki pamięci w React zazwyczaj wpisują się w trzy wzorce.

Osierocone globalne listenery. useEffect podpięty pod window lub document w celu śledzenia pozycji przewijania, naciśnięć klawiszy lub zdarzeń zmiany rozmiaru okna. Jeśli efekt nie zwraca funkcji czyszczącej (cleanup function), która wywołuje removeEventListener, listener przetrwa przez cały czas życia strony. Ponieważ listener jest domknięciem, utrzymuje on cały zakres (scope) komponentu długo po tym, jak React odmontował komponent.

Nieoczyszczone obserwatory. IntersectionObserver i ResizeObserver są potężne, ale tworzą natywne referencje poza kontrolą Reacta. Jeśli zainicjujesz obserwator wewnątrz komponentu i zapomnisz o wywołaniu disconnect() w fazie czyszczenia, obserwator będzie trzymał docelowy węzeł DOM, a węzeł DOM będzie trzymał React fibers, propsy i stan.

Pułapki domknięć. Gdy definiujesz funkcję wewnątrz komponentu i przekazujesz ją do biblioteki zewnętrznej, globalnego cache'u lub nawet do setTimeout, funkcja ta tworzy domknięcie nad każdą zmienną w swoim zakresie leksykalnym. Jeśli zewnętrzny właściciel zachowa tę funkcję, zachowa również cały zakres Twojego komponentu.

Wzorce czyszczenia, które naprawdę działają

Naprawa wycieku oznacza przerwanie każdej ścieżki utrzymującej (retaining path), którą znalazłeś w zrzucie pamięci.

Zawsze zwracaj funkcję czyszczącą z useEffect. Jeśli dodajesz listener w efekcie, usuń go właśnie tam.

Używaj useCallback dla każdego handlera, który przypisujesz do DOM lub window. Bez tego każdy render tworzy nową referencję funkcji. Jeśli wywołasz addEventListener z jedną referencją, a później removeEventListener z inną, usunięcie nie powiedzie się po cichu. Oryginalny listener pozostanie na window na zawsze. useCallback utrzymuje stabilną referencję, dzięki czemu operacje dodawania i usuwania dokładnie do siebie pasują.

Obsługuj obserwatory z tą samą dyscypliną. Przechowuj instancję obserwatora w ref lub lokalnej zmiennej wewnątrz efektu. W funkcji czyszczącej wywołaj observer.disconnect(). Nie zakładaj, że odmontowanie komponentu zabije obserwator. Tak się nie dzieje.

Jeśli Twój komponent publikuje cokolwiek w globalnej przestrzeni nazw lub w usłudze typu singleton, usuń te referencje podczas odmontowywania. Silnik V8 może odzyskać pamięć tylko wtedy, gdy obiekt jest całkowicie nieosiągalny. Pozostawienie hooka na window lub wpisu w Map na poziomie modułu tworzy niewidzialny most, który powoduje ciągły wzrost sterty (heap).

Kluczowy wniosek

Wycieki pamięci nie powodują natychmiastowego awaryjnego zamknięcia aplikacji. Kumulują się, jeden odłączony węzeł po drugim, podczas długich sesji użytkownika. Rozwiązaniem nie jest aktualizacja biblioteki ani flaga kompilatora. Jest nim nawyk sprawdzania logiki czyszczenia za pomocą zrzutów sterty (heap snapshots).

Zrób pomiar bazowy, uruchom podejrzany proces, wymuś odśmiecanie pamięci (garbage collection) i porównaj wyniki. Jeśli delta wykazuje wzrost, sprawdź ścieżkę utrzymującą (retaining path), znajdź listenera lub obserwatora, który nie powinien istnieć, i odetnij referencję. Powtórz test. Gdy delta pozostanie płaska, oznacza to, że faktycznie rozwiązałeś problem. Twoja aplikacja pozostanie responsywna, a użytkownicy nie stracą swojej pracy przez zawieszoną kartę przeglądarki.