YASD-TECH
YASD TECH
# React

addEventListener と useEffect(登録・クリーンアップ・onClickとの使い分け)

投稿日:2026/8/8

更新日:2026/8/8

ttitleImage

addEventListeneruseEffect(登録・クリーンアップ・onClick との使い分け)

ブラウザのイベントを拾う addEventListener を、React のコンポーネントの中でどう扱うか。
「なぜ useEffect の中に書くのか」「なぜ後始末が要るのか」「onClick で済む場面はどれか」をまとめる。


結論

  • addEventListener は「特定のイベントが発火したら、この関数を実行して」をブラウザに登録する API。
  • React では useEffect の中で登録し、返り値のクリーンアップ関数で removeEventListener するのが基本形。
  • buttoninput など React が管理している要素は onClick / onChange を使うaddEventListener を直接触るのは window / document / React 外の DOM が相手のときだけ。
  • 解除に渡す関数は登録したときと同一の参照でなければならない。無名関数を直接渡すと解除できない。

1. addEventListener そのもの

形はこれだけ。

ts
対象.addEventListener("イベント名", 発火したときに実行する関数);
ts
window.addEventListener("keydown", (event) => {
  console.log("キーが押された", event.key);
});

「ブラウザ全体で keydown が発火したら、この関数を実行して」という意味になる。

登録先(対象)はいろいろある。

ts
// ウィンドウサイズの変更
window.addEventListener("resize", () => {
  console.log("画面サイズが変わった");
});

// タブの表示状態の変更(別タブに切り替わった、戻ってきた)
document.addEventListener("visibilitychange", () => {
  console.log("表示状態が変わった", document.visibilityState);
});

// 特定の DOM 要素
button.addEventListener("click", () => {
  console.log("ボタンが押された");
});

登録は ブラウザ側に残る。React のレンダリングとは別世界の話なので、React が勝手に片付けてくれることはない。


2. React では useEffect の中で登録する

addEventListener は「React の外側にあるブラウザへの登録」なので、
コンポーネントが表示されたタイミングで登録し、消えるタイミングで解除する必要がある。
これはまさに useEffect の担当範囲になる。

tsx
useEffect(() => {
  const handleKeyDown = (event: KeyboardEvent) => {
    console.log(event.key);
  };

  window.addEventListener("keydown", handleKeyDown);

  return () => {
    window.removeEventListener("keydown", handleKeyDown);
  };
}, []);

この形が基本形。ポイントは return の中で解除していること

sequenceDiagram
    participant R as React
    participant E as useEffect
    participant B as ブラウザ

    R->>E: マウント
    E->>B: addEventListener("keydown", handleKeyDown)
    Note over B: 以降、キー入力のたびに<br/>handleKeyDown が呼ばれる
    R->>E: アンマウント
    E->>B: removeEventListener("keydown", handleKeyDown)
    Note over B: 登録が消える

なぜレンダリング中(コンポーネント本体)に書いてはいけないかというと、
本体は再レンダリングのたびに実行されるので、レンダリングの回数だけ登録が積み上がるため。


3. クリーンアップを書かないとどうなるか

return を省くと、登録がブラウザ側に残り続ける。

flowchart TD
    A["マウント: リスナー登録 (1個)"] --> B["再マウント"]
    B --> C["リスナー登録 (2個目)"]
    C --> D["再マウント"]
    D --> E["リスナー登録 (3個目)"]
    E --> F["1回のイベントで<br/>ハンドラが3回実行される"]

具体的に起きること。

症状 原因
コンポーネントを消したのに処理が動く 登録がブラウザに残っている
1回の操作でハンドラが 2回・3回と実行される マウントのたびに登録が積まれた
消えたコンポーネントの setState で警告・エラーになる 生き残ったハンドラが古いクロージャを掴んでいる
ページを行き来するほど重くなる 解除されないリスナーがメモリに溜まる

React の StrictMode(開発時)はマウント → アンマウント → マウントをわざと 1 往復多く実行する。
クリーンアップを書き忘れていると、開発中に「2回実行される」形で先に露見する。これはバグではなく検知装置。


4. onClick との使い分け

普通のボタンクリックに addEventListener は要らない。

tsx
<button onClick={() => console.log("clicked")}>
  Click
</button>

React の onClick は React が内部でイベントを束ねて配ってくれる仕組み(合成イベント)で、
登録も解除も React がやってくれる。自分で後始末を書く必要がない。

判断はこれで足りる。

flowchart TD
    A{"イベントを拾いたい対象は?"} -->|"JSX で書いた要素<br/>(button, input, form...)"| B["onClick / onChange などを使う<br/>後始末は React 任せ"]
    A -->|"window / document"| C["useEffect + addEventListener"]
    A -->|"React 外の DOM<br/>外部ライブラリ / ポータル外の要素"| C
    C --> D["return で removeEventListener"]
onClick などの props useEffect + addEventListener
対象 JSX で書いた React 管理下の要素 window / document / React 外の DOM
登録タイミング React が管理 自分で useEffect に書く
解除 React が自動でやる 自分で removeEventListener する
代表例 ボタン押下、入力、フォーム送信 keydown(ショートカット)、resizescrollvisibilitychangebeforeunload

windowdocument のイベント、React のイベントでは拾いにくいものを扱うときに addEventListener を使う、という整理でよい。


5. ハマりどころ

5-1. 無名関数を直接渡すと解除できない

tsx
// ❌ 解除されない
useEffect(() => {
  window.addEventListener("resize", () => console.log("resize"));
  return () => {
    window.removeEventListener("resize", () => console.log("resize"));
  };
}, []);

見た目が同じでも別々の関数オブジェクトなので、removeEventListener はどれを消せばいいか分からず何もしない。
必ず変数に入れて、同じ参照を渡す。

tsx
// ✅ 同じ参照を渡す
useEffect(() => {
  const handleResize = () => console.log("resize");
  window.addEventListener("resize", handleResize);
  return () => window.removeEventListener("resize", handleResize);
}, []);

5-2. 依存配列が [] だと古い値を掴む

ハンドラは定義された時点の値を閉じ込める(クロージャ)。[] のままだと初回の値のまま固定される。

tsx
const [count, setCount] = useState(0);

useEffect(() => {
  const handleKeyDown = () => {
    console.log(count); // ❌ ずっと 0 のまま
  };
  window.addEventListener("keydown", handleKeyDown);
  return () => window.removeEventListener("keydown", handleKeyDown);
}, []);

対処は 2 つ。

tsx
// A. 依存配列に入れる(値が変わるたびに登録し直される)
useEffect(() => {
  const handleKeyDown = () => console.log(count);
  window.addEventListener("keydown", handleKeyDown);
  return () => window.removeEventListener("keydown", handleKeyDown);
}, [count]);

// B. 最新値を state から読む形にする(登録し直しが不要)
useEffect(() => {
  const handleKeyDown = () => setCount((prev) => prev + 1);
  window.addEventListener("keydown", handleKeyDown);
  return () => window.removeEventListener("keydown", handleKeyDown);
}, []);

5-3. window はサーバー側に存在しない

SSR(Next.js の App Router など)では、モジュールのトップレベルやレンダリング中に window を触ると
window is not defined で落ちる。

tsx
// ❌ サーバーで評価されて落ちる
window.addEventListener("resize", handleResize);

export default function Component() { ... }

useEffect はブラウザでしか実行されないので、中に入れておけばこの問題は起きない
ただしその場合コンポーネントはクライアントコンポーネントである必要がある("use client")。

5-4. 複数登録するなら AbortController が楽

signal を渡しておくと、abort() 1 回で全部まとめて解除できる。

tsx
useEffect(() => {
  const controller = new AbortController();
  const { signal } = controller;

  window.addEventListener("resize", handleResize, { signal });
  window.addEventListener("keydown", handleKeyDown, { signal });
  document.addEventListener("visibilitychange", handleVisibility, { signal });

  return () => controller.abort(); // まとめて解除
}, []);

5-5. scroll / touchmovepassive: true を検討する

スクロール系のリスナーは、ブラウザが「preventDefault() されるかも」と待つぶんスクロールが引っかかることがある。
preventDefault() しないと分かっているなら明示する。

ts
window.addEventListener("scroll", handleScroll, { passive: true });

参考

Index

  • addEventListener と useEffect(登録・クリーンアップ・onClick との使い分け)
  • 結論
  • 1. addEventListener そのもの
  • 2. React では useEffect の中で登録する
  • 3. クリーンアップを書かないとどうなるか
  • 4. onClick との使い分け
  • 5. ハマりどころ
  • 5-1. 無名関数を直接渡すと解除できない
  • 5-2. 依存配列が [] だと古い値を掴む
  • 5-3. window はサーバー側に存在しない
  • 5-4. 複数登録するなら AbortController が楽
  • 5-5. scroll / touchmove は passive: true を検討する
  • 参考