React useEffect の cleanup が期待通り呼ばれない
useEffect が返した関数(cleanup)はアンマウント時と次回 effect 実行直前に走る。
React 18 の Strict Mode では開発時にマウント直後にも一度実行される。
cleanup が呼ばれないように見える場合は、依存配列の漏れ、effect 内で例外を投げた、登録時と解除時で関数参照が違う、のいずれかを疑う。
公開: 更新:
要約
useEffect で return () => { ... } を返した関数は cleanup と呼ばれ、(1) コンポーネントのアンマウント時、(2) 依存配列の値が変わって effect が再実行される直前、の 2 タイミングで呼ばれる。
「呼ばれない」ように見える原因の多くは、依存配列のずれによって effect 自体が再実行されておらず、登録した subscription が古いまま残っている、または React 18 Strict Mode の二重実行を別の問題と誤認しているケース。
実行例
node:20 の React 19.3.0 で、addEventListener と removeEventListener に別々の無名関数を渡すと cleanup は走るのにアンマウント後のクリックでも呼び出し回数が 1 から 2 に増え、effect の中で作った同じ関数を渡すと 1 のままだった。
依存配列が [id] の effect では、id を 1 から 2 に変えると id=1 の cleanup の後に id=2 の effect が走り、アンマウント時に id=2 の cleanup が走った。
$ node bad.js
mounted, click -> calls = 1
cleanup ran
unmounted, click -> calls = 2$ node good.js
mounted, click -> calls = 1
cleanup ran
unmounted, click -> calls = 1$ node timing.js
effect id=1
cleanup id=1
effect id=2
cleanup id=2— 2026-10-06 時点の出力
検証環境
- 検証日
- 実行環境
node:20Debian GNU/Linux 12 (bookworm)- バージョン
- Node.js 20.20.2
- npm 10.8.2
- Python 3.11.2
- Git 2.39.5
- React 19.3.0
この記事の「実行例」は、上記の環境で実際にコマンドを実行して得られた出力をそのまま掲載しています。 再現手順はリポジトリの検証スクリプトとして管理し、定期的に再実行して出力を更新しています。
よくある原因
- 依存配列が
[]で固定されており、props が変わっても effect が再実行されない(= cleanup も走らない)。
当然 stale な subscription が残る。 - React 18 Strict Mode は dev 時に意図的に「マウント → アンマウント → 再マウント」を行う。
cleanup が 2 回走ったように見えるが、これは「冪等性チェック」のための仕様。 - effect の同期処理中で例外を投げて return まで到達せず、cleanup 関数が登録されないままになる。
addEventListener('click', () => f())のように登録ごとに別の無名関数を渡し、removeEventListenerで別の関数参照を渡してしまい解除できない。
解決策
1. cleanup と登録で同じ参照を使う
useEffect(() => {
const handler = (e: MouseEvent) => console.log(e.clientX);
window.addEventListener("click", handler);
return () => window.removeEventListener("click", handler);
}, []);handler を effect 内で定義し、登録と解除で同じ変数を参照する。
2. 依存配列を正しく宣言する
eslint-plugin-react-hooks の exhaustive-deps ルールを有効化し、警告に従って依存を埋める。
必要な値を意図的に外したいときも、コメントで根拠を残す。
3. Strict Mode の二重実行を前提に書く
useEffect(() => {
const id = setInterval(() => tick(), 1000);
return () => clearInterval(id);
}, []);setInterval と clearInterval が対になっていれば、二重実行されてもタイマーは 1 本に収束する。
詳細は 公式ドキュメント (useEffect)(新しいタブで開く)。
4. AbortController で fetch / listener を一括解除
useEffect(() => {
const ctrl = new AbortController();
fetch("/api/data", { signal: ctrl.signal })
.then(r => r.json())
.then(setData)
.catch(e => { if (e.name !== "AbortError") console.error(e); });
return () => ctrl.abort();
}, []);非同期処理を cleanup できるようにしておくと「アンマウント後の setState」警告も消える。