Reactの副作用、その「忘れ物」がアプリケーションを殺す:イベントリスナーとクリーンアップの深淵
Reactというフレームワークは、宣言的UIの美しさの裏で、DOMという命令的(Imperative)な怪物を飼いならしている。特に`window`や`document`といったグローバルなスコープに対してイベントリスナーを付与する場合、我々はフレームワークの保護下を離れ、ブラウザのメモリ管理の最前線に立つことになる。
「とりあえず`useEffect`に`addEventListener`を書いておけばいい」という甘い考えは、SPA(Single Page Application)の長寿命なライフサイクルにおいて、やがてメモリリークという名の癌細胞を生む。今回は、この制御不能になりがちなイベントリスナーを、プロとしてどう飼いならすべきか、その極致を解説しよう。
—
1. 「クリーンアップ関数」という名の契約
Reactの`useEffect`が返す関数。これは単なるオマケではない。コンポーネントがアンマウントされる直前、あるいは依存配列が更新される直前に実行される「ファイナル・セーフガード」だ。
ここでの鉄則は、「登録したものは、必ずその対となる関数で抹消する」ことだ。特に`removeEventListener`を疎かにすると、コンポーネントが破棄された後も、JavaScriptの実行コンテキスト内にクロージャが残り続け、ガベージコレクションを阻害する。
実践:堅牢なイベントリスナー管理
import { useEffect } from ‘react’;
const useWindowResize = (handler) => {
useEffect(() => {
// リスナー登録
window.addEventListener(‘resize’, handler);
// クリーンアップ関数
// 依存配列が空の場合、コンポーネントのアンマウント時に一度だけ実行される
return () => {
window.removeEventListener(‘resize’, handler);
console.log(‘リスナーを抹消。メモリリークを未然に防ぐ。’);
};
}, [handler]); // handlerが参照として安定していることが前提
};
—
2. 潜む罠:依存配列の「再登録コスト」
ここで一つの疑問が生じるはずだ。「もし`handler`が親コンポーネントから渡される関数で、再レンダリングのたびに参照先が変わっていたら?」
依存配列に`handler`を入れると、レンダリングのたびに`remove`と`add`が繰り返される。これはブラウザのイベントループに対して不要なオーバーヘッドを強いることになる。
これを解決するには、二つの道がある。
1. `useCallback`で関数を固定する:親側で`useCallback`を使い、参照の等価性を保つ。
2. `useRef`で最新のハンドラを追跡する:これぞ現場のテクニックだ。
応用:useEventRef によるパフォーマンス最適化
`useRef`を使い、イベントハンドラの最新の状態を常に保持することで、`useEffect`の依存配列を空(`[]`)に保ち、再登録のコストをゼロにする。
import { useEffect, useRef } from ‘react’;
const useEventListener = (eventName, handler, element = window) => {
const savedHandler = useRef(handler);
// レンダリングのたびに最新の関数をRefに格納
useEffect(() => {
savedHandler.current = handler;
}, [handler]);
useEffect(() => {
const isSupported = element && element.addEventListener;
if (!isSupported) return;
// ラッパー関数を定義し、内部で最新のRefを参照させる
const eventListener = (event) => savedHandler.current(event);
element.addEventListener(eventName, eventListener);
// 確実にクリーンアップ
return () => {
element.removeEventListener(eventName, eventListener);
};
}, [eventName, element]); // 依存配列を最小化
};
—
3. 非同期レースコンディションと「破棄されたコンポーネント」
イベントリスナーの中で`setState`を呼ぶ際、コンポーネントが既にアンマウントされていると、Reactは「メモリリークが発生しました」と警告を出す。
これを避けるために、フラグを用いたクリーンアップ制御を行うのが通例だが、私はあえて「クリーンアップ関数内で状態をリセットする」というアプローチを推奨する。非同期の競合を避けるためには、イベントの発生源を断つのが最も合理的だからだ。
4. 伝説のチーフアーキテクトからの助言
最後に、コード以外の視点で一つ。
大規模アプリケーションにおいて、イベントリスナーは「どこに書かれたか」が分からなくなると、デバッグ不可能なスパゲッティコードの温床となる。
- グローバルなイベントはカスタムフックに隔離せよ。
- `window`や`document`への直接アクセスを避けるためのラッパー層を作れ。
- ブラウザのメモリプロファイラで「デタッチされたDOMノード」が増え続けていないか定期的に監視せよ。
コードは書くことよりも、どう「捨てる」かが重要だ。イベントリスナーのライフサイクルを完全に制御できた時、あなたのアプリケーションは、どれほど複雑な状態遷移を繰り返しても、決して重くなることはない。
さあ、あなたのコードから「忘れ物」を消し去る準備はできたか?

コメント