【実務・中級編】 イベントリスナーの登録と解除 – React実践ガイド

やあ。最近、プロダクトのパフォーマンス改善やメモリリークの調査に追われていないかい?
中級から一歩抜け出して「シニア」の領域に足を踏み入れようとしている君なら、一度は「`useEffect`の中でイベントリスナーを登録したはいいが、本当に正しくクリーンアップできているのか?」と冷や汗をかいた経験があるはずだ。

世の中の入門記事を見ると「`useEffect`の返り値で`removeEventListener`を返せばOK!」とだけ書いてある。だが、実務の現場はそんなに甘くない。依存配列の指定ミスでリスナーが毎レンダリングごとに再登録されたり、古いクロージャのせいでstateが常に初期値のまま参照されたり……。

今回は、`window`や`document`に対するイベントリスナーの登録と解除をテーマに、Reactのライフサイクルとブラウザの裏側の動きを紐解きながら、現場でそのまま使える「本物のベストプラクティス」を授けよう。

—

なぜイベントリスナーの「解除」を忘れると地獄を見るのか?

まず、ブラウザの裏側の話をしよう。
`window.addEventListener(‘resize’, handleResize)` のようなコードを実行すると、ブラウザのグローバルオブジェクト(`window`)側から、君が書いたコンポーネント内の関数(コールバック)へ向けて「参照」が張られる。

もし、コンポーネントが画面から消えた(アンマウントされた)にもかかわらず、`removeEventListener`を呼び出していなかったらどうなるか?
ブラウザは「まだこの関数は使われるかもしれない」と判断し、コンポーネントが保持していたDOMノードやstate、果てには巨大なオブジェクトの参照まで、メモリ上に保持し続ける。これがメモリリークだ。

さらに恐ろしいのは、シングルページアプリケーション(SPA)において、ユーザーが画面を行き来するたびにゾンビのようなイベントリスナーが何重にも蓄積されていく現象だ。リサイズイベントのたびに何十個もの古い関数が発火し、メインスレッドがブロックされてブラウザがカクつく――。現場で「最近なんだかアプリが重いぞ」という時の原因のトップランナーがこれだ。

—

現場でよくある「やってはいけない」アンチパターン

まずは、中級者がやりがちな「一見動くけれど、実は地雷原なコード」を見てみよう。

// 【アンチパターン】これはいけない例
import { useState, useEffect } from ‘react’;

function BadWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);

useEffect(() => {
// 依存配列がない、または空っぽの場合の罠
const handleResize = () => {
setWidth(window.innerWidth);
};

window.addEventListener(‘resize’, handleResize);

// クリーンアップを書き忘れた、あるいは不完全
// または、ここにリクエストや重い処理が絡むと悲惨なことに
}, []);

return

現在のウィンドウ幅: {width}px

;
}

このコード、一見すると動く。だが、もし`handleResize`の内部で最新の他のStateやPropsを参照しようとした瞬間、いわゆる「クロージャの古い参照(Stale Closure問題)」の罠にハマる。リスナーが登録された瞬間の環境をそのまま閉じ込めてしまうため、何度リサイズしてもStateが更新されない、あるいは古い値に基づいてバグを引き起こすのだ。

—

実務で通用する:安全で美しいイベントリスナー管理の極意

では、どう書くのが正解なのか。
ここでは、実務のプロダクション環境でも胸を張ってコードレビューに出せる、ウィンドウのリサイズを検知するカスタムフックに近い実用的なコンポーネントのサンプルコードを見ていこう。

import { useState, useEffect } from ‘react’;

export default function SafeWindowTracker() {
const [windowSize, setWindowSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});

useEffect(() => {
// 1. イベントハンドラーの定義
// この関数はエフェクト内(またはuseCallback)で定義することで、
// 常に最新のスコープにアクセスできるようにする。
const handleResize = () => {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
});
};

// 2. ブラウザのイベントリスナーに登録
window.addEventListener(‘resize’, handleResize);

// 3. 【最重要】クリーンアップ関数の返却
// コンポーネントがアンマウントされる、あるいは再実行される直前に必ず走る
return () => {
window.removeEventListener(‘resize’, handleResize);
};

// 4. 依存配列の制御
// windowオブジェクト自体はイミュータブル(不変)なので空配列[]で問題ないが、
// もしハンドラー内でコンポーネントのPropsやStateに強く依存する場合は、
// useCallbackを使うか、エフェクトの再実行コストを意識する必要がある。
}, []);

return (

ウィンドウサイズ監視コンポーネント

幅: {windowSize.width}px

高さ: {windowSize.height}px

);
}

このコードが優れている理由

1. 確実にクリーンアップが走る点
Reactは、コンポーネントがDOMツリーから取り除かれる(アンマウント)まさにその瞬間に、`useEffect`が返した関数(クリーンアップ関数)を実行する。これにより、ブラウザのメモリ上から不要な参照が綺麗に掃除され、メモリリークを完全に防げる。
2. React 18のStrict Mode(厳格モード)への耐性
開発環境のStrict Modeでは、Reactは「マウント ➔ 即アンマウント ➔ 再マウント」というシミュレーションを意図的に行う。ここで正しく`removeEventListener`が実装されていないと、開発中にイベントが二重登録されてコンソールがうるさくなったりバグったりする。このコードなら、Strict Modeの荒波も綺麗に乗りこなせる。

—

さらに現場のパフォーマンスを高めるためのプロのTips

もし対象のイベントが `resize` や `scroll`、あるいはリアルタイム性の高いキーボード入力(`keydown`)などのように、ミリ秒単位で高頻度に発火するものだったらどうする?

ここで「毎イベントごとにsetStateを呼んで再レンダリングを走らせる」のは、フロントエンドエンジニアとしての怠慢だ。ブラウザのメインスレッドが悲鳴を上げる。
そんな時は、必ずスロットリング(Throttling)やデバウンシング(Debouncing)をかませるべきだ。

import { useState, useEffect } from ‘react’;

// 簡易的なスロットリング関数(実際はlodashや自前のユーティリティを使うと良い)
function throttle(func, limit) {
let inThrottle;
return function() {
const args = arguments;
const context = this;
if (!inThrottle) {
func.apply(context, args);
inThrottle = true;
setTimeout(() => (inThrottle = false), limit);
}
};
}

export default function OptimizedScrollTracker() {
const [scrollY, setScrollY] = useState(0);

useEffect(() => {
// 高頻度イベントには必ずthrottleを適用する
const handleScroll = throttle(() => {
setScrollY(window.scrollY);
}, 200); // 200msに1回しかstateを更新しない

window.addEventListener(‘scroll’, handleScroll, { passive: true }); // { passive: true } でスクロールパフォーマンスを爆上げ

return () => {
window.removeEventListener(‘scroll’, handleScroll);
};
}, []);

return (

スクロール位置: {scrollY}px

);
}

ここでコッソリと仕込んだもう一つのプロの技が、`{ passive: true }` というオプションだ。
これを第三引数に渡すことで、ブラウザに対して「このイベントハンドラー内で`preventDefault()`は呼び出しませんよ」と宣言できる。これにより、ブラウザはスクロールの描画パフォーマンスを最適化(スレッドをブロックせずにスクロールを継続)してくれる。こういう細かな配慮の積み重ねが、ジュニアとシニアの決定的な差を生むんだ。

—

まとめ

イベントリスナーの管理は、Reactにおける「副作用制御」の基本にして奥義だ。

  • 登録したら、必ずクリーンアップ関数で解除する。
  • Stale Closure(古いクロージャ)に気をつけ、必要な依存関係を正しく把握する。
  • 高頻度イベント(scroll, resize等)にはスロットリングと `{ passive: true }` を検討する。

この3つを頭に叩き込んでおけば、どんな大規模なReactアプリケーションを任されても、メモリリークや不可解なバグに足元をすくわれることはなくなるはずだ。
さあ、自分のコードベースに戻って、野放しになっているイベントリスナーがないか確認しに行こうか。

コメント

タイトルとURLをコピーしました