useEffectのクリーンアップ関数とexhaustive-depsルールを正しく理解する

useEffectのクリーンアップ関数とexhaustive-depsルールを正しく理解する

useEffectのクリーンアップ関数の動作原理と、react-hooks/exhaustive-depsルールの役割を、実際のデバウンスフックのコードを題材に解説します。stale closure問題やリアクティブな値の定義も整理し、依存配列を正しく書くための知識をまとめました。
2026.06.19

はじめに

Reactのコードレビューでこんな1行に出会ったことはありませんか?

useEffect(() => () => clearDebounce(), [clearDebounce]);

一見すると矢印が多くて読みにくいですが、これはReactのuseEffectクリーンアップパターンを簡潔に書いたものです。

この記事では、実際のデバウンスフックのコードをきっかけに、以下の疑問を掘り下げていきます。

  • useEffectのクリーンアップ関数はどう動くのか?
  • コンポーネントがアンマウントされたとき、タイマーはどうなるのか?
  • react-hooks/exhaustive-depsルールは何を守ってくれるのか?
  • そもそも「リアクティブな値」とは何か?

useEffectのクリーンアップ関数の仕組み

react-useeffect-cleanup-and-exhaustive-deps-lifecycle

基本構造: setupとcleanup

useEffectに渡す関数は、setup関数と呼ばれます。この関数がreturnする関数がcleanup関数です。

useEffect(() => {
  // setup: マウント時(および依存配列の変更時)に実行
  return () => {
    // cleanup: アンマウント時(および次のsetup実行前)に実行
  };
}, [依存値]);

Reactがcleanupを実行するタイミングは2つあります。

  1. コンポーネントのアンマウント時
  2. 依存配列の値が変わり、effectが再実行される直前(古いcleanupを実行してから新しいsetupを実行)

冒頭のコードを展開する

冒頭の1行を展開すると、以下のようになります。

useEffect(() => {
  // setup: 何もしない
  return () => clearDebounce(); // cleanup: デバウンスタイマーをクリア
}, [clearDebounce]);

() => () => clearDebounce() は、「setup関数がcleanup関数を返す」というパターンの省略記法です。setupでは何もせず、アンマウント時(または依存値変更時)にcleanup(clearDebounce())を実行します。

アンマウント時のタイマークリーンアップ

問題: アンマウント後にコールバックが発火する

デバウンスを使うシーンを考えてみます。

  1. ユーザーが検索フィールドにテキストを入力する
  2. デバウンスタイマーが1秒のカウントダウンを開始する
  3. 1秒経過する前に、何らかの理由でそのフィールドを含むコンポーネントがアンマウントされる

クリーンアップがなければ、タイマーはそのまま動き続けます。1秒後にコールバックが発火し、すでに存在しないコンポーネントに対して検索を実行しようとします。

解決: useEffectのcleanupでタイマーを破棄

useEffect(() => () => clearDebounce(), [clearDebounce]);

このパターンをuseDebounceフック内に組み込んでおけば、フックを使うすべてのコンポーネントで自動的にアンマウント時のタイマークリーンアップが行われます。各コンポーネントで個別にクリーンアップ処理を書く必要はありません。

react-hooks/exhaustive-depsルール

このルールが守るもの

react-hooks/exhaustive-depsは、useEffectuseCallbackuseMemoの依存配列に、内部で使用しているすべてのリアクティブな値が含まれているかをチェックする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内で使用されているため、ルールに従い依存配列に含めています。clearDebounceuseCallbackで生成されているため参照が安定しており、実際には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ルールに従い、使用するリアクティブな値をすべて列挙することで、予測可能で安全なコンポーネントを書くことができます。

この記事をシェアする

関連記事