こんにちは。フロントエンドの現場を渡り歩いていると、いまだに `useEffect` の依存配列を「怒られないためのおまじない」くらいに捉えているコードベースに遭遇して、冷や汗が出ることがあります。特に `window` や `document` といったグローバルなオブザーバーに対するイベントリスナーの管理は、一歩間違えばメモリリークの温床になり、SPA(Single Page Application)特有の「ゾンビコンポーネント」が裏でCPUを焼き尽くす原因になります。
今回は、ブラウザのイベントループとReactのレンダリングサイクルの狭間で何が起きているのか、その内部挙動の深淵を覗きつつ、プロダクション品質のイベントリスナー制御について語っていきましょう。
—
なぜ、素朴な `useEffect` では破綻するのか
まずは、多くのジュニア〜ミドルクラスのエンジニアがやりがちな、一見正しそうに見えて実は地雷を踏んでいるコードを見てください。
import { useState, useEffect } from ‘react’;
export function BadWindowWidth() {
const [windowWidth, setWindowWidth] = useState(window.innerWidth);
useEffect(() => {
// 画面幅が変わるたびにstateを更新しようとしている
const handleResize = () => setWindowWidth(window.innerWidth);
window.addEventListener(‘resize’, handleResize);
// クリーンアップ
return () => {
window.removeEventListener(‘resize’, handleResize);
};
}, []); // 依存配列が空だから大丈夫…?
return
;
}
一見して「完璧にクリーンアップしているじゃないか」と思われるかもしれません。しかし、もしこの `handleResize` の中で、コンポーネントのスコープ外にある最新のPropsやStateを参照し始めた途端、このコードは「Stale Closure(古いクロージャ)」の罠に落ちます。
さらに厄介なのは、React 18のStrict Mode(開発環境)や、Concurrent Rendererによる「画面外でのゆらぎ(mounting/unmountingの頻発)」です。イベントリスナーの登録と解除が高速に連続すると、ブラウザのメインスレッドに無駄な負荷がかかり、最悪の場合、GC(ガベージコレクション)が追いつかずにDOMノードがメモリ上に残存し続けるメモリリークを引き起こします。
—
堅牢なイベントリスナー管理のアーキテクチャ
真に堅牢なアプリケーションを目指すのであれば、私たちは以下の3点に厳格に向き合う必要があります。
1. クロージャの鮮度担保(Stale Closureの回避)
2. 不要なリスナーの再登録コストの削減(パフォーマンス最適化)
3. 確実なクリーンアップ(メモリリークの完全防止)
これを極限まで洗練させたカスタムフックのパターンを見てみましょう。イベントハンドラ自体の再生成コストと、クロージャがキャプチャする値の古さを同時に解決する「Refパターン」の応用です。
実践:プロダクションレベルの `useEventListener` フック
import { useEffect, useRef } from ‘react’;
// WindowやDocumentのイベント型を安全に推論するためのユーティロジック
type WindowEventType = keyof WindowEventMap;
export function useEventListener
eventName: K,
handler: (event: WindowEventMap[K]) => void,
element: Window | Document = window
): void {
// 最新のハンドラ関数を保持するRef
// これにより、ハンドラが変わるたびにuseEffectを再発火させる必要がなくなる
const savedHandler = useRef(handler);
useEffect(() => {
savedHandler.current = handler;
}, [handler]);
useEffect(() => {
// ターゲットがイベントリスナーをサポートしているか検証
const isSupported = element && element.addEventListener;
if (!isSupported) return;
// 実際のイベントリスナー。常に最新のハンドラを指し示す
const listener: typeof handler = (event) => savedHandler.current(event);
element.addEventListener(eventName, listener);
// クリーンアップ関数:コンポーネントのアンマウント時、
// または eventName / element が変化した時に確実にリスナーを剥ぎ取る
return () => {
element.removeEventListener(eventName, listener);
};
}, [eventName, element]);
}
このアプローチの美しさは、「リスナーの登録・解除という高コストなDOM操作の頻度」と「ハンドラ内部で最新のState/Propsを参照したいというビジネスロジックの要求」を完全にデカップリングしている点にあります。
—
高度な最適化:スロットリングとデバウンスの罠
ウィンドウの `resize` や `scroll`、あるいは `mousemove` のような高頻度イベントを扱う場合、`addEventListener` の中に直接重い処理を書くのは御法度です。ブラウザの描画フレームレート(通常60fps=約16.6msごと)を完全に殺してしまいます。
ここで、Lodash等の `throttle` や `debounce` を使いたくなりますが、`useEffect` の中で無造作にこれらを呼び出すと、毎レンダリングごとに新しいタイマーや関数インスタンスが生成され、クリーンアップが機能不全に陥るという致命的なバグを生みます。
必ず `useCallback` や `useMemo`、あるいは専用のカスタムフックを用いて、関数のアイデンティティを担保しなければなりません。
import { useState, useEffect, useCallback } from ‘react’;
import { useEventListener } from ‘./useEventListener’; // 先ほどのフック
export function OptimizedScrollPosition() {
const [scrollY, setScrollY] = useState(0);
// スクロールイベントは高頻度で発火するため、フレームレートに同期させるか、
// あるいはロジック側でスロットリングを効かせる必要がある。
// ここでは簡易的にrequestAnimationFrameベースの制御を想定。
const handleScroll = useCallback(() => {
window.requestAnimationFrame(() => {
setScrollY(window.scrollY);
});
}, []);
// 自作の堅牢なフックで購読
useEventListener(‘scroll’, handleScroll);
return (
);
}
—
アーキテクトからの提言:クリーンアップは「おまじない」ではなく「契約」である
Reactにおける `useEffect` のクリーンアップ関数は、単なるお片付けルーチンではありません。それは「このコンポーネントが生存している期間と、外部リソース(ブラウザのグローバルイベント)のライフサイクルを同期させるための厳粛な契約(Contract)」です。
この契約を軽視し、なんとなく `[]` を置いて済ませているコードは、やがて巨大化したアプリケーションにおいて、原因不明のメモリリークや、すでにアンマウントされたコンポーネントのStateを更新しようとした際のあの有名な警告(Can’t perform a React state update on an unmounted component…)を呼び寄せます。
ブラウザのイベントループ、クロージャのメカニズム、そしてReactのレンダリングライフサイクル。これら3つの歯車がどのように噛み合っているのかを解像度高く理解していれば、あなたの書くコードは、単に「動く」だけでなく、極限まで洗練された「美しいシステム」へと昇華されるはずです。
さあ、エディタを開いて、プロジェクト内の `window.addEventListener` が正しく飼いならされているか、確認の旅に出るとしましょうか。

コメント