投稿日:2026/8/8
更新日:2026/8/8

addEventListener と useEffect(登録・クリーンアップ・onClick との使い分け)ブラウザのイベントを拾う addEventListener を、React のコンポーネントの中でどう扱うか。
「なぜ useEffect の中に書くのか」「なぜ後始末が要るのか」「onClick で済む場面はどれか」をまとめる。
addEventListener は「特定のイベントが発火したら、この関数を実行して」をブラウザに登録する API。useEffect の中で登録し、返り値のクリーンアップ関数で removeEventListener するのが基本形。button や input など React が管理している要素は onClick / onChange を使う。addEventListener を直接触るのは window / document / React 外の DOM が相手のときだけ。addEventListener そのもの形はこれだけ。
対象.addEventListener("イベント名", 発火したときに実行する関数);
window.addEventListener("keydown", (event) => {
console.log("キーが押された", event.key);
});
「ブラウザ全体で keydown が発火したら、この関数を実行して」という意味になる。
登録先(対象)はいろいろある。
// ウィンドウサイズの変更
window.addEventListener("resize", () => {
console.log("画面サイズが変わった");
});
// タブの表示状態の変更(別タブに切り替わった、戻ってきた)
document.addEventListener("visibilitychange", () => {
console.log("表示状態が変わった", document.visibilityState);
});
// 特定の DOM 要素
button.addEventListener("click", () => {
console.log("ボタンが押された");
});
登録は ブラウザ側に残る。React のレンダリングとは別世界の話なので、React が勝手に片付けてくれることはない。
useEffect の中で登録するaddEventListener は「React の外側にあるブラウザへの登録」なので、
コンポーネントが表示されたタイミングで登録し、消えるタイミングで解除する必要がある。
これはまさに useEffect の担当範囲になる。
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: 登録が消える
なぜレンダリング中(コンポーネント本体)に書いてはいけないかというと、
本体は再レンダリングのたびに実行されるので、レンダリングの回数だけ登録が積み上がるため。
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回実行される」形で先に露見する。これはバグではなく検知装置。
onClick との使い分け普通のボタンクリックに addEventListener は要らない。
<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(ショートカット)、resize、scroll、visibilitychange、beforeunload |
window や document のイベント、React のイベントでは拾いにくいものを扱うときに addEventListener を使う、という整理でよい。
// ❌ 解除されない
useEffect(() => {
window.addEventListener("resize", () => console.log("resize"));
return () => {
window.removeEventListener("resize", () => console.log("resize"));
};
}, []);
見た目が同じでも別々の関数オブジェクトなので、removeEventListener はどれを消せばいいか分からず何もしない。
必ず変数に入れて、同じ参照を渡す。
// ✅ 同じ参照を渡す
useEffect(() => {
const handleResize = () => console.log("resize");
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
[] だと古い値を掴むハンドラは定義された時点の値を閉じ込める(クロージャ)。[] のままだと初回の値のまま固定される。
const [count, setCount] = useState(0);
useEffect(() => {
const handleKeyDown = () => {
console.log(count); // ❌ ずっと 0 のまま
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, []);
対処は 2 つ。
// 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);
}, []);
window はサーバー側に存在しないSSR(Next.js の App Router など)では、モジュールのトップレベルやレンダリング中に window を触るとwindow is not defined で落ちる。
// ❌ サーバーで評価されて落ちる
window.addEventListener("resize", handleResize);
export default function Component() { ... }
useEffect はブラウザでしか実行されないので、中に入れておけばこの問題は起きない。
ただしその場合コンポーネントはクライアントコンポーネントである必要がある("use client")。
AbortController が楽signal を渡しておくと、abort() 1 回で全部まとめて解除できる。
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(); // まとめて解除
}, []);
scroll / touchmove は passive: true を検討するスクロール系のリスナーは、ブラウザが「preventDefault() されるかも」と待つぶんスクロールが引っかかることがある。preventDefault() しないと分かっているなら明示する。
window.addEventListener("scroll", handleScroll, { passive: true });