De browsertab van je gebruiker bevriest na dertig minuten. De UI hapert. Vervolgens crasht de browser met een out-of-memory-fout.

Je bekijkt de componentcode regel voor regel en ziet niets mis. Dat is het frustrerende aan memory leaks in React. De bug zit niet in je JSX-syntax of je hook-logica. Hij bevindt zich in de kloof tussen je component en de garbage collector van de browser. Een verloren event listener of een langdurige closure voorkomt dat de collector het geheugen vrijgeeft. Je unmount een component, maar een enkele achtergebleven referentie houdt de volledige boom in leven in de heap. Je kunt deze leaks niet vinden door alleen code te lezen. Het probleem blijft verborgen onder de oppervlakte, onzichtbaar voor het oog en stil in unit tests.

Waarom leaks in het volle zicht verborgen blijven

JavaScript-engines zoals V8 beheren het geheugen automatisch. Wanneer er geen referentiepad meer bestaat van de root naar een object, markeert de engine dat object als garbage en geeft het de ruimte vrij. Dit proces werkt goed, totdat een verborgen referentie langer blijft bestaan dan de bedoeling is.

In React ontstaat het gevaar vaak op de grens tussen componenten en de DOM. Je koppelt misschien een resize listener aan het window binnen een modal, of je abonneert je op een WebSocket in een dashboard-widget. Wanneer de gebruiker de modal sluit of weg navigeert, wordt de component unmounted. Als de subscriptie blijft bestaan, ziet de engine een geldige referentie van het globale window-object naar je handler, en van je handler terug naar de closure van de component. De component, de props, de state en de volledige subtree van DOM-nodes blijven in het geheugen vastgezet. Na honderden interacties stapelen deze vastgezette objecten zich op. Het geheugengebruik stijgt in een zaagtandpatroon dat nooit volledig terugzakt.

Het probleem met enterprise dashboards

Dit gebeurt het vaakst in enterprise dashboards waar gebruikers urenlang op één pagina blijven. Denk aan monitoringpanels, analytics-weergaven of ticketing-systemen. Een gebruiker opent een detail-modal, filtert een grote dataset of wisselt van tabblad binnen een single-page app. Elke individuele interactie voelt prima aan. Na verloop van tijd stapelen orphaned nodes en detached listeners zich echter op. De applicatie wordt niet traag door een enkele dure render, maar omdat de heap groot genoeg wordt om frequente en dure garbage collection-pauzes te triggeren.

Stop met gissen naar je useEffect hooks. De enige manier om te weten of er een leak bestaat, is door de heap direct te meten. Chrome DevTools geeft je die inzichtelijkheid.

Leaks opsporen met Chrome DevTools

Je hebt een reproduceerbare reeks acties nodig en een paar minuten gerichte aandacht. Open je applicatie in Chrome, start DevTools en ga naar het tabblad Memory.

Maak een baseline op. Selecteer Heap snapshot en klik op Take snapshot. Hiermee leg je elk object vast dat momenteel in de JavaScript heap leeft en krijg je het begingeheugen te zien. Doe dit nadat de pagina in zijn initiële idle-toestand is gekomen, niet tijdens het initiële laden, zodat je alleen de groei meet die wordt veroorzaakt door gebruikersacties.

Trigger de actie. Voer exact de UI-interactie uit waarvan je vermoedt dat deze de leak veroorzaakt. Open en sluit een modal, schakel een complexe grafiek in of uit, of verander een route en navigeer terug. Breng de app na afloop terug naar de oorspronkelijke visuele staat. Deze stap is cruciaal. Je wilt dat de UI er identiek uitziet als tijdens de baseline. Als de heap is gegroeid terwijl de UI leeg lijkt, heb je sterk bewijs voor een leak.

Forceer garbage collection. Klik op het prullenbak-icoon in het Memory-tabblad. Dit triggert een volledige GC-cyclus en wist tijdelijke objecten die terecht hebben overleefd tot de volgende collectie. Wat overblijft zijn de echte leaks: objecten die verzameld hadden moeten worden, maar in leven werden gehouden door accidentele referenties.

Maak een tweede snapshot. Klik opnieuw op Take snapshot. Je hebt nu twee 'foto's' van de heap gemaakt onder dezelfde UI-omstandigheden.

Vergelijk de resultaten. Verander de weergave van Summary naar Comparison. Stel de baseline in op je eerste snapshot en de snapshot die je vergelijkt op de tweede. De Comparison-weergave somt elke objectcategorie op en toont de delta: de netto verandering in het aantal objecten tussen de twee opnames.

Sorteer op Delta. Zoek naar categorieën die aanzienlijk zijn toegenomen. Focus vooral op Detached HTMLElement en React fiber nodes. Een detached HTML-element is een DOM-node die niet langer is gekoppeld aan de actieve documentboom, maar die nog steeds door een JavaScript-referentie wordt vastgehouden. Dit zijn de smoking guns. Ze zouden niet mogen bestaan nadat je een modal hebt gesloten of een component hebt unmounted.

Het lezen van het Retaining Path

Wanneer je een losgekoppeld element selecteert in de snapshot, toont Chrome het retaining path in het onderste paneel. Dit pad is een keten van referenties van de root naar het geselecteerde object. Volg dit pad zorgvuldig. Je zult vaak een event listener, een IntersectionObserver, een setInterval ID, of een closure vinden die naar een specifieke regel in je component wijst.

Zoek naar namen die je herkent. Als je een listener ziet die aan window is gekoppeld met een functienaam uit je eigen codebase, dan heb je het anker gevonden. Het object dat die listener vasthoudt, houdt je volledige component in leven. Soms loopt de keten via een third-party library. Controleer in die gevallen of de library een expliciete teardown call verwacht die je bent vergeten aan te roepen in een cleanup function.

De grondoorzaken aanpakken

Zodra je het retaining path hebt geïdentificeerd, is de oplossing meestal mechanisch, maar het vereist discipline binnen het team.

Gebruik cleanup functions. Retourneer altijd een cleanup function in useEffect wanneer je listeners toevoegt aan window of document. Als je effect zich abonneert op een resize event, verwijder die abonnement dan voordat de component unmount. De cleanup wordt uitgevoerd wanneer React de component afbreekt, wat je een gegarandeerde hook geeft om externe verbindingen te verbreken.

Stabiliseer referenties. Wikkel je handlers in useCallback. Dit zorgt ervoor dat je exact dezelfde functiereferentie doorgeeft aan removeEventListener als de referentie die je oorspronkelijk aan addEventListener hebt doorgegeven. Als je een inline functie registreert zoals window.addEventListener('resize', () => { ... }) en deze later probeert te verwijderen met een andere inline functie, zullen de referenties niet overeenkomen. De listener blijft dan gekoppeld. De closure daarin houdt de state van je component in leven. useCallback met een stabiele dependency array voorkomt deze mismatch in identiteit.

Verbreek globale links. Houd er rekening mee dat de event target-objecten van de browser, zoals window en document, de gehele levensduur van de pagina blijven bestaan. Elke referentie van hen naar jouw component fungeert als een globaal anker. Door de listener te verwijderen, verbreek je dat anker, waardoor de V8 engine de component state en DOM-nodes kan opruimen tijdens de volgende garbage collection cycle.

Denk aan een modal die de breedte van het venster bijhoudt. Zonder cleanup wordt er elke keer dat de gebruiker de modal opent een nieuwe listener toegevoegd. De oude listeners worden nooit losgekoppeld omdat de component-instanties waartoe ze behoren niet meer bestaan, terwijl de functies zelf anoniem waren en verloren zijn gegaan. Het gebruik van een genaamde handler die is ingepakt in useCallback, plus een cleanup function die removeEventListener aanroept, sluit de cirkel netjes af.

De belangrijkste les

Memory leaks in React maken zich zelden kenbaar met een duidelijke foutmelding. Ze maken zich kenbaar door een tabblad dat steeds zwaarder wordt naarmate het langer open blijft staan. Verspil geen tijd met speculeren over welke hook de schuldige is. Open het Memory-tabblad, dwing garbage collection af en vergelijk snapshots. Laat de heap profiler je het exacte retaining path laten zien. Schrijf vervolgens de cleanup function, stabiliseer de callback-referentie en verbreek de globale link. Je gebruikers zullen de fix niet direct merken, maar ze zullen wel merken dat het dashboard aan het einde van de dag nog steeds soepel draait.