Your user's browser tab freezes after thirty minutes. The UI stutters. Then the browser crashes with an out-of-memory error.

You review the component code line by line and see nothing wrong. That is the maddening part of memory leaks in React. The bug does not live in your JSX syntax or your hook logic. It lives in the gap between your component and the browser's garbage collector. A stray event listener or a long-lived closure stops the collector from reclaiming memory. You unmount a component, but a single lingering reference keeps the entire tree alive in the heap. You cannot find these leaks by just reading code. The problem stays hidden under the surface, invisible to the eye and silent in unit tests.

Why Leaks Hide in Plain Sight

JavaScript engines like V8 manage memory automatically. When no reference path exists from the root to an object, the engine marks that object as garbage and reclaims the space. This process works well until a hidden reference survives longer than you intended.

In React, the danger often appears at the boundary between components and the DOM. You might attach a resize listener to the window inside a modal, or subscribe to a WebSocket in a dashboard widget. When the user closes the modal or navigates away, the component unmounts. If the subscription survives, the engine sees a valid reference from the global window object down to your handler, and from your handler back into the component's closure. The component, its props, its state, and its entire subtree of DOM nodes stay pinned in memory. Over hundreds of interactions, these pinned objects accumulate. Memory usage climbs in a sawtooth pattern that never fully drops back down.

The Enterprise Dashboard Problem

This happens most in enterprise dashboards where users stay on one page for hours. Think of monitoring panels, analytics views, or ticketing systems. A user opens a detail modal, filters a large dataset, or switches tabs within a single-page app. Each individual interaction feels fine. Over time, though, orphaned nodes and detached listeners accumulate. The application slows not because of a single expensive render, but because the heap grows large enough to trigger frequent and expensive garbage collection pauses.

Stop guessing about your useEffect hooks. The only way to know if a leak exists is to measure the heap directly. Chrome DevTools gives you that visibility.

Hunting Leaks with Chrome DevTools

You need a reproducible sequence and a few minutes of focused attention. Open your application in Chrome, launch DevTools, and navigate to the Memory tab.

Record a baseline. Select Heap snapshot and click Take snapshot. This captures every object currently living in the JavaScript heap and shows you the starting memory. Do this after the page has settled into its initial idle state, not during initial load, so you measure only the growth caused by user actions.

Trigger the action. Perform the exact UI interaction you suspect causes the leak. Open and close a modal, toggle a complex chart, or change a route and navigate back. Once finished, return the app to its original visual state. This step is crucial. You want the UI to look identical to how it looked during the baseline. If the heap has grown despite the UI appearing empty, you have strong evidence of a leak.

Force garbage collection. Click the trash can icon in the Memory tab. This triggers a full GC cycle and clears temporary objects that legitimately survived until the next collection. What remains are the real leaks, objects that should have been collected but were held alive by accidental references.

Take a second snapshot. Click Take snapshot again. You now have two photographs of the heap taken under the same UI conditions.

Compare results. Change the view from Summary to Comparison. Set the baseline to your first snapshot and the compared snapshot to the second. The Comparison view lists every object category and shows you the delta, the net change in object counts between the two captures.

Sort by Delta. Look for categories that increased significantly. Focus especially on Detached HTMLElement and React fiber nodes. A detached HTML element is a DOM node that is no longer attached to the active document tree, yet some JavaScript reference still holds it. These are smoking guns. They should not exist after you have closed a modal or unmounted a component.

Reading the Retaining Path

スナップショット内で切り離された要素を選択すると、Chromeは下部のパネルに保持パス(retaining path)を表示します。このパスは、ルートから選択したオブジェクトに至るまでの参照の連鎖です。これを注意深く辿ってください。イベントリスナー、IntersectionObserversetInterval の ID、あるいはコンポーネント内の特定の行を指しているクロージャなどがよく見つかります。

見覚えのある名前を探してください。もし、自身のコードベースにある関数名を持つリスナーが window にアタッチされているのを見つけたら、それがアンカー(固定点)です。そのリスナーを保持しているオブジェクトが、コンポーネント全体をメモリ上に生存させ続けています。時として、この連鎖がサードパーティライブラリを経由していることもあります。その場合は、ライブラリが明示的な終了処理(teardown)の呼び出しを必要としていないか、クリーンアップ関数での呼び出しを忘れていないかを確認してください。

根本的な原因の修正

保持パスを特定できれば、修正方法は通常機械的なものですが、チーム全体での規律が求められます。

クリーンアップ関数を使用する。 windowdocument にリスナーを追加する場合は、必ず useEffect 内でクリーンアップ関数を返してください。もしエフェクトが resize イベントを購読しているなら、コンポーネントがアンマウントされる前にその購読を解除します。クリーンアップは React がコンポーネントを破棄する際に実行されるため、外部との接続を断ち切るための確実なフックとなります。

参照を安定させる。 ハンドラーは useCallback でラップしてください。これにより、最初に addEventListener に渡したのと全く同じ関数参照を removeEventListener に渡すことができます。もし window.addEventListener('resize', () => { ... }) のようにインライン関数を登録し、後で別のインライン関数を使って削除しようとしても、参照が一致しません。その結果、リスナーはアタッチされたままになり、その中のクロージャがコンポーネントの状態を生存させ続けてしまいます。安定した依存関係配列を持つ useCallback を使用することで、この同一性の不一致を防ぐことができます。

グローバルなリンクを断ち切る。 windowdocument といったブラウザのイベントターゲットオブジェクトは、ページの生存期間中ずっと存在し続けることを忘れないでください。それらからコンポーネントへの参照は、すべてグローバルなアンカーとして機能します。リスナーを削除することでそのアンカーが壊れ、次のガベージコレクションのサイクル中に V8 エンジンがコンポーネントの状態や DOM ノードを掃去できるようになります。

ウィンドウの幅を追跡するモーダルを例に考えてみましょう。クリーンアップがないと、ユーザーがモーダルを開くたびに新しいリスナーがアタッチされます。それらが属するコンポーネントのインスタンスは既に消滅しているため、古いリスナーは決して解除されません。しかも、関数自体が匿名であった場合、それらは失われてしまいます。useCallback でラップされた名前付きのハンドラーを使用し、さらに removeEventListener を呼び出すクリーンアップ関数を組み合わせることで、このループを綺麗に閉じることができます。

実践的な教訓

React におけるメモリリークが、明確なエラーメッセージとともに現れることはめったにありません。代わりに、タブを開き続けている時間が長くなるにつれて、そのタブがどんどん重くなっていくことでその存在を知らせてきます。どのフックが原因か推測することに時間を費やしてはいけません。Memory タブを開き、ガベージコレクションを強制実行して、スナップショットを比較してください。ヒーププロファイラーに正確な保持パスを示させましょう。それから、クリーンアップ関数を書き、コールバックの参照を安定させ、グローバルなリンクを断ち切るのです。ユーザーが修正を直接感じることはないでしょう。しかし、一日の終わりになってもダッシュボードがスムーズに動作し続けていることには、気づくはずです。