
useEffectのクリーンアップ関数とexhaustive-depsルールを正しく理解する
はじめに
Reactのコードレビューでこんな1行に出会ったことはありませんか?
useEffect(() => () => clearDebounce(), [clearDebounce]);
一見すると矢印が多くて読みにくいですが、これはReactのuseEffectクリーンアップパターンを簡潔に書いたものです。
この記事では、実際のデバウンスフックのコードをきっかけに、以下の疑問を掘り下げていきます。
useEffectのクリーンアップ関数はどう動くのか?- コンポーネントがアンマウントされたとき、タイマーはどうなるのか?
react-hooks/exhaustive-depsルールは何を守ってくれるのか?- そもそも「リアクティブな値」とは何か?
useEffectのクリーンアップ関数の仕組み

基本構造: setupとcleanup
useEffectに渡す関数は、setup関数と呼ばれます。この関数がreturnする関数がcleanup関数です。
useEffect(() => {
// setup: マウント時(および依存配列の変更時)に実行
return () => {
// cleanup: アンマウント時(および次のsetup実行前)に実行
};
}, [依存値]);
Reactがcleanupを実行するタイミングは2つあります。
- コンポーネントのアンマウント時
- 依存配列の値が変わり、effectが再実行される直前(古いcleanupを実行してから新しいsetupを実行)
冒頭のコードを展開する
冒頭の1行を展開すると、以下のようになります。
useEffect(() => {
// setup: 何もしない
return () => clearDebounce(); // cleanup: デバウンスタイマーをクリア
}, [clearDebounce]);
() => () => clearDebounce() は、「setup関数がcleanup関数を返す」というパターンの省略記法です。setupでは何もせず、アンマウント時(または依存値変更時)にcleanup(clearDebounce())を実行します。
アンマウント時のタイマークリーンアップ
問題: アンマウント後にコールバックが発火する
デバウンスを使うシーンを考えてみます。
- ユーザーが検索フィールドにテキストを入力する
- デバウンスタイマーが1秒のカウントダウンを開始する
- 1秒経過する前に、何らかの理由でそのフィールドを含むコンポーネントがアンマウントされる
クリーンアップがなければ、タイマーはそのまま動き続けます。1秒後にコールバックが発火し、すでに存在しないコンポーネントに対して検索を実行しようとします。
解決: useEffectのcleanupでタイマーを破棄
useEffect(() => () => clearDebounce(), [clearDebounce]);
このパターンをuseDebounceフック内に組み込んでおけば、フックを使うすべてのコンポーネントで自動的にアンマウント時のタイマークリーンアップが行われます。各コンポーネントで個別にクリーンアップ処理を書く必要はありません。
react-hooks/exhaustive-depsルール
このルールが守るもの
react-hooks/exhaustive-depsは、useEffect・useCallback・useMemoの依存配列に、内部で使用しているすべてのリアクティブな値が含まれているかをチェックするESLintルールです。
// NG: lint error — countが依存配列に含まれていない
const [count, setCount] = useState(0);
useEffect(() => {
console.log(count);
}, []);
// OK
useEffect(() => {
console.log(count);
}, [count]);
なぜ必要か: stale closure問題
依存配列に値を入れ忘れると、stale closure(古いクロージャ)が発生します。effectが古い値をキャプチャしたまま更新されず、バグの原因になります。
useEffect(() => {
const id = setInterval(() => {
console.log(count); // 常に0を出力し続ける(更新されない)
}, 1000);
return () => clearInterval(id);
}, []); // countが依存配列にない → stale closure
冒頭のコードでの依存配列
useEffect(() => () => clearDebounce(), [clearDebounce]);
clearDebounceはeffect内で使用されているため、ルールに従い依存配列に含めています。clearDebounceはuseCallbackで生成されているため参照が安定しており、実際にはeffectが再実行されることはほぼありません。依存配列に含めているのは、ランタイムの挙動のためではなく、ルールへの準拠のためです。
ルール名の由来: "exhaustive"
「exhaustive」は「網羅的」という意味です。「重要そうな依存値だけ入れる」のではなく、使用しているリアクティブな値をすべて入れることを要求します。依存配列は完全な列挙でなければなりません。
// 3つのリアクティブな値を使用 → 3つすべてを依存配列に
useEffect(() => {
fetchData(userId, token, locale);
}, [userId, token, locale]); // exhaustive(網羅的)
ルールを無効化したくなる場面と、なぜ避けるべきか
よくあるケースは「マウント時に1回だけ実行したい」という意図です。
// 「マウント時だけfetchしたい」という意図
useEffect(() => {
fetchUser(userId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
しかし、これはuserIdが変わっても再fetchされないというバグを隠しています。ルールを無効化する代わりに、effectの構造自体を見直すべきです。
- 本当に初回だけ実行したい場合:
useRefで値を固定する - 一部の変更だけスキップしたい場合: effectを分割する、または条件分岐を内部に入れる
ルールを無効化したくなったら、それはeffectの設計を見直すサインです。
リアクティブな値とは
依存配列に入れるべき「リアクティブな値」とは、レンダリング間で変わりうる値のことです。
リアクティブな値(依存配列に含める)
| 種類 | 例 |
|---|---|
useStateの値 |
count, isOpen |
useReducerの値 |
state |
| Props | props.onChange, props.userId |
| 上記から派生した変数・関数 | コンポーネント内で定義された計算値や関数 |
useCallback / useMemoの戻り値 |
安定した参照だが、依存配列には含める |
リアクティブでない値(依存配列に含めなくてよい)
| 種類 | 例 | 理由 |
|---|---|---|
| コンポーネント外の定数 | const API_URL = "..." |
レンダリングで変わらない |
useRefの.current |
timerRef.current |
変更してもre-renderを起こさない |
setState / dispatch |
setCount, dispatch |
Reactが安定した参照を保証 |
重要なのは、変数だけでなく関数もリアクティブな値になりうることです。コンポーネント内で定義された関数は、レンダリングごとに新しい参照が生成されます(useCallbackで安定化していない場合)。
まとめ
| 概念 | ポイント |
|---|---|
useEffectのcleanup |
return した関数がアンマウント時と依存値変更時に実行される |
| デバウンスのクリーンアップ | フック内にcleanupを組み込むことで、利用側で意識する必要がなくなる |
exhaustive-deps |
使用するリアクティブな値をすべて依存配列に入れることで、stale closureを防ぐ |
| リアクティブな値 | レンダリング間で変わりうる値。変数も関数も含まれる |
| ルールの無効化 | ほとんどの場合、effectの設計見直しで対応できる。無効化は最終手段 |
useEffectの依存配列は「最適化のヒント」ではなく「正確な宣言」です。exhaustive-depsルールに従い、使用するリアクティブな値をすべて列挙することで、予測可能で安全なコンポーネントを書くことができます。







