Reactコンポーネントにおける useEffect の使用用途のひとつに「イベントリスナーの登録と解除」があります。特にウィンドウリサイズ、キーボード入力、スクロールイベントなど、グローバルなUIイベントをハンドリングする必要があるケースでは、この構造が必須となります。

しかし実際のコードレビューにおいては、次のような設計上の問題が頻出します:

  • リスナーの登録と解除がセットになっていない
  • 不要なタイミングで毎回登録・解除が繰り返されている
  • handler関数が毎回再定義されてリスナーが無効化できていない

本記事では、レビューアーとして useEffect でイベントリスナーを安全に設計するための構造判断軸を明確にします。

正しい構造:登録と解除がペアになっているか?

まず確認すべきは、「登録したリスナーが確実に解除される構造になっているか」です。以下は基本的な正しい構造です。

イベントリスナー構造
useEffect(() => {
  const handleResize = () => console.log("resized");
  window.addEventListener("resize", handleResize);

  return () => {
    window.removeEventListener("resize", handleResize);
  };
}, []);
コードレビュー
@Reviewer: 登録と解除がペアで構成されており、副作用の責任範囲が明確です。handlerが同一関数であり、removeが有効に機能する構造です。

この構造により、コンポーネントのアンマウント時にリスナーが解除され、不要なイベント反応やメモリリークを防止できます。

よくある問題パターンとレビュー観点

パターン1:解除が記述されていない

解除漏れの例
useEffect(() => {
  const handleKeyDown = (e: KeyboardEvent) => {
    console.log(e.key);
  };
  window.addEventListener("keydown", handleKeyDown);
}, []);
コードレビュー
@Reviewer: `removeEventListener` の記述がなく、コンポーネントがアンマウントされた後もイベントが生き残る構造です。クリーンアップ関数で明示的に解除処理を記述してください。

このような構造はバグとして現れにくいものの、長期的にイベントが蓄積しパフォーマンス悪化の原因になります。

パターン2:ハンドラ関数が毎回新しく定義されている

handlerのインスタンスが変わる構造
useEffect(() => {
  const handler = () => console.log("click");
  window.addEventListener("click", handler);

  return () => {
    window.removeEventListener("click", handler);
  };
}, [someValue]); // ← handlerも毎回定義される
コードレビュー
@Reviewer: `handler` 関数がEffect内で毎回再定義されており、依存変数が変更されるたびに `removeEventListener` が効かなくなり、イベントが残存する可能性があります。useCallbackでhandlerをメモ化することを検討してください。

useCallbackを使ったハンドラの安定化

useCallbackによる安定化
const handleScroll = useCallback(() => {
  console.log("scrolled");
}, []); // ← 依存を明示的に管理

useEffect(() => {
  window.addEventListener("scroll", handleScroll);
  return () => window.removeEventListener("scroll", handleScroll);
}, [handleScroll]);
コードレビュー
@Reviewer: `useCallback` により `handleScroll` の参照が安定しており、イベントの登録・解除が常に正しく機能する構造です。

このように、handlerのインスタンスを安定させることでReactの再レンダリングと副作用の実行タイミングのズレを防げます。

複数イベントの登録と責務分離

1つの useEffect で複数のイベントを扱うと、責務が混在して可読性が下がります。

責務混在の構造
useEffect(() => {
  const resize = () => {};
  const keydown = () => {};
  window.addEventListener("resize", resize);
  window.addEventListener("keydown", keydown);

  return () => {
    window.removeEventListener("resize", resize);
    window.removeEventListener("keydown", keydown);
  };
}, []);
コードレビュー
@Reviewer: 複数のイベントリスナーが1つのEffectに混在しています。責務が異なるため、Effectを分割して明示することで構造を整理できます。

可視化:ハンドラ安定性とEffect構造の関係

UML Diagram

カスタムHookへの責務移譲

イベントリスナーの責務が複数のコンポーネントに共通している場合、カスタムHookとして抽出すると構造が整理されます。

カスタムHookによる責務抽出
function useResize(handler: () => void) {
  useEffect(() => {
    window.addEventListener("resize", handler);
    return () => window.removeEventListener("resize", handler);
  }, [handler]);
}
利用側
useResize(handleResize);
コードレビュー
@Reviewer: カスタムHookによって副作用の責務が明確化され、呼び出し側コンポーネントの構造が簡潔になっています。

レビュー観点チェックリスト

イベントリスナー設計は useEffect の副作用の中でもバグが埋もれやすい箇所です。レビューアーは構造上の登録/解除対応、handlerの安定性、責務の分離を冷静に読み解くことが求められます。