Karta przeglądarki Twojego użytkownika zamarza po trzydziestu minutach. Interfejs użytkownika (UI) zacina się. Następnie przeglądarka ulega awarii z błędem braku pamięci (out-of-memory error).

Przeglądasz kod komponentu linijka po linijce i nie widzisz niczego niepokojącego. To jest właśnie najbardziej irytujący aspekt wycieków pamięci w React. Błąd nie tkwi w Twojej składni JSX ani w logice hooków. Żyje on w luce między Twoim komponentem a mechanizmem odśmiecania pamięci (garbage collector) przeglądarki. Pojedynczy, zbędny listener zdarzeń lub długożyjące domknięcie (closure) uniemożliwia kolektorowi odzyskanie pamięci. Odmontowujesz komponent, ale pojedyncze, pozostające w pamięci odniesienie sprawia, że całe drzewo pozostaje aktywne w stercie (heap). Nie znajdziesz tych wycieków, po prostu czytając kod. Problem pozostaje ukryty pod powierzchnią, niewidoczny dla oka i niesłyszalny w testach jednostkowych.

Dlaczego wycieki ukrywają się na widoku

Silniki JavaScript, takie jak V8, zarządzają pamięcią automatycznie. Gdy nie istnieje żadna ścieżka odniesienia od korzenia (root) do obiektu, silnik oznacza ten obiekt jako śmieci i odzyskuje miejsce. Proces ten działa sprawnie, dopóki ukryte odniesienie nie przetrwa dłużej, niż zakładałeś.

W React niebezpieczeństwo często pojawia się na granicy między komponentami a DOM. Możesz przypisać listener resize do obiektu window wewnątrz modala lub zasubskrybować WebSocket w widżecie pulpitu nawigacyjnego. Gdy użytkownik zamknie modal lub przejdzie do innej strony, komponent zostaje odmontowany. Jeśli subskrypcja przetrwa, silnik widzi poprawne odniesienie od globalnego obiektu window aż do Twojego handlera, a z handlera z powrotem do domknięcia (closure) komponentu. Komponent, jego propsy, stan oraz całe poddrzewo węzłów DOM pozostają „przypięte” w pamięci. W trakcie setek interakcji te przypięte obiekty kumulują się. Zużycie pamięci rośnie w formie wzoru piły zębatej, który nigdy w pełni nie opada.

Problem pulpitów nawigacyjnych klasy Enterprise

Dzieje się to najczęściej w pulpitach nawigacyjnych klasy enterprise, gdzie użytkownicy spędzają na jednej stronie wiele godzin. Pomyśl o panelach monitorujących, widokach analitycznych czy systemach zgłoszeniowych. Użytkownik otwiera modal ze szczegółami, filtruje duży zestaw danych lub przełącza karty w aplikacji typu single-page app. Każda pojedyncza interakcja wydaje się w porządku. Jednak z czasem gromadzą się osierocone węzły i odłączone listenery. Aplikacja zwalnia nie z powodu jednego kosztownego renderowania, lecz dlatego, że sterta rośnie na tyle, by wywoływać częste i kosztowne pauzy podczas odśmiecania pamięci.

Przestań zgadywać, co dzieje się w Twoich hookach useEffect. Jedynym sposobem, aby dowiedzieć się, czy istnieje wyciek, jest bezpośredni pomiar sterty. Chrome DevTools zapewnia taką widoczność.

Polowanie na wycieki za pomocą Chrome DevTools

Potrzebujesz powtarzalnej sekwencji działań i kilku minut skupienia. Otwórz swoją aplikację w Chrome, uruchom DevTools i przejdź do karty Memory.

Zapisz punkt odniesienia (baseline). Wybierz Heap snapshot i kliknij Take snapshot. To przechwyci każdy obiekt znajdujący się obecnie w stercie JavaScript i pokaże początkowe zużycie pamięci. Zrób to po tym, jak strona ustabilizuje się w swoim początkowym stanie bezczynności, a nie podczas ładowania, aby mierzyć jedynie wzrost spowodowany działaniami użytkownika.

Wywołaj akcję. Wykonaj dokładnie tę interakcję z UI, która Twoim zdaniem powoduje wyciek. Otwórz i zamknij modal, przełącz skomplikowany wykres lub zmień trasę (route) i wróć. Po zakończeniu przywróć aplikację do jej oryginalnego stanu wizualnego. Ten krok jest kluczowy. Chcesz, aby UI wyglądało identycznie jak w momencie tworzenia punktu odniesienia. Jeśli sterta urosła, mimo że UI wydaje się puste, masz silny dowód na istnienie wycieku.

Wymuś odśmiecanie pamięci. Kliknij ikonę kosza na karcie Memory. To wyzwoli pełny cykl GC i wyczyści tymczasowe obiekty, które zgodnie z prawem przetrwały do następnego cyklu. To, co pozostanie, to rzeczywiste wycieki – obiekty, które powinny zostać usunięte, ale zostały podtrzymane przez przypadkowe odniesienia.

Zrób drugi snapshot. Kliknij ponownie Take snapshot. Masz teraz dwa „zdjęcia” sterty wykonane w tych samych warunkach interfejsu.

Porównaj wyniki. Zmień widok z Summary na Comparison. Ustaw punkt odniesienia na swój pierwszy snapshot, a porównywany snapshot na drugi. Widok Comparison wymienia każdą kategorię obiektów i pokazuje deltę, czyli całkowitą zmianę liczby obiektów między dwoma przechwyceniami.

Sortuj według Delty. Szukaj kategorii, które znacząco wzrosły. Skup się szczególnie na Detached HTMLElement oraz węzłach React fiber. Odłączony (detached) element HTML to węzeł DOM, który nie jest już podłączony do aktywnego drzewa dokumentu, a mimo to jakieś odniesienie w JavaScript wciąż go trzyma. To „dymiące pistolety” (smoking guns) – jednoznaczne dowody winy. Nie powinny one istnieć po zamknięciu modala lub odmontowaniu komponentu.

Czytanie ścieżki utrzymującej (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.