· yuta

useEffectの依存配列とクロージャの罠を実例で解説

#react#typescript#frontend

前回の記事「React useEffectとは何者なのかを理解する」で、useEffect の依存配列は「いつ実行するか」を決めるものだと整理しました。今回はその依存配列と密接に関わる、もう一つの落とし穴を扱います。依存配列を空にしたら、なぜか古い値を参照し続けるという現象です。

これは俗に stale closure(古くなったクロージャ) と呼ばれるバグで、useEffect を使ううえで最もハマりやすい問題の一つです。今回も架空のオンライン料理教室アプリ CookMate を例にします。

起きる現象:カウンターが1で止まる

CookMateのレッスン視聴ページに、「視聴時間を1秒ごとにカウントする」機能を作ります。素直に書くとこうなります。

export function LessonPage() {
  const [watchSeconds, setWatchSeconds] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      setWatchSeconds(watchSeconds + 1); // ❌ ここが罠
    }, 1000);

    return () => clearInterval(timer);
  }, []);

  return <p>視聴時間: {watchSeconds}秒</p>;
}

一見自然なコードですが、実行すると 0秒 → 1秒 で止まり、それ以上増えません。setInterval は1秒ごとにちゃんと呼ばれているのに、表示は 1 のまま変わらないのです。

なぜ止まるのか:クロージャが値を「閉じ込めている」

原因を理解するには、まず クロージャ(closure) の性質を思い出す必要があります。JavaScriptの関数は、定義された時点のスコープにある変数を「閉じ込めた」状態で保持します。

useEffect のコールバック関数も例外ではありません。useEffect(fn, []) はマウント時の1回だけ実行されます。つまり fn の中身は、最初の描画時点でのスコープを閉じ込めたまま、二度と作り直されません。

1回目の描画: watchSeconds = 0
  → useEffect実行([] なので初回だけ)
  → setInterval に渡したコールバックは
    「watchSeconds = 0」を閉じ込めたクロージャ

以降、setWatchSeconds(1) が呼ばれて再描画されても
  → useEffect は再実行されない(依存配列が [] だから)
  → setInterval のコールバックは相変わらず
    「watchSeconds = 0」を見たまま
  → 毎秒 setWatchSeconds(0 + 1) が呼ばれ続ける
  → 表示は 1 のまま変化しない

再描画のたびに watchSeconds 自体は更新されていますが、setInterval に渡された関数は初回描画時の watchSeconds(=0)を閉じ込めたままなので、何度呼ばれても 0 + 1 にしかなりません。これが「古い値を参照し続ける」= stale closure の正体です。

解決策1:依存配列に値を入れる

最初に思いつく対処は、watchSeconds を依存配列に入れることです。

useEffect(() => {
  const timer = setInterval(() => {
    setWatchSeconds(watchSeconds + 1);
  }, 1000);

  return () => clearInterval(timer);
}, [watchSeconds]); // 値が変わるたびに再実行

これで一応動きますが、代償があります。watchSeconds が変わるたびに、クリーンアップ(clearInterval)→ 再実行(新しい setInterval) が走ります。つまり1秒ごとにタイマーを破棄して作り直しているということです。今回は実害が出にくい規模ですが、無駄な処理であることには変わりません。

解決策2:関数型更新で「値そのもの」を参照しない

より筋の良い直し方は、setWatchSeconds に関数を渡す「関数型更新」を使うことです。

useEffect(() => {
  const timer = setInterval(() => {
    setWatchSeconds((prev) => prev + 1); // ✅ 最新のstateを受け取れる
  }, 1000);

  return () => clearInterval(timer);
}, []); // 依存配列は空のままでよい

setWatchSeconds(prev => prev + 1) は、クロージャに閉じ込められた watchSeconds を直接読むのではなく、Reactが呼び出し時点で渡してくれる最新のstate(prev)を使って計算します。そのため、クロージャが古い値を持っていること自体が問題にならなくなります。

「stateの次の値が、直前のstateから計算できる」場合は、依存配列に値を追加するのではなく、まず関数型更新で解決できないかを検討するのが定石です。

別パターン:イベントリスナーが古いpropsを見る

stale closureは setInterval に限りません。イベントリスナーでも同じことが起きます。CookMateの予約フォームで、「選択中の日付」を使って確認処理をするケースを考えます。

function ReservationForm({ selectedDate }: { selectedDate: string }) {
  useEffect(() => {
    function handleConfirm() {
      console.log(`${selectedDate} で予約します`); // ❌ 古いselectedDateを見る
    }

    window.addEventListener('confirm-reservation', handleConfirm);
    return () => window.removeEventListener('confirm-reservation', handleConfirm);
  }, []); // selectedDate が変わっても再登録されない

  return <DatePicker />;
}

ユーザーが日付を選び直しても、イベントリスナーは初回描画時の selectedDate を閉じ込めたままなので、常に最初に選んだ日付でログが出てしまいます。原理は先ほどのカウンターと同じです。

直し方:depsに入れる、またはrefで最新値を持つ

素直な直し方は、依存配列に selectedDate を入れてリスナーを登録し直すことです。

useEffect(() => {
  function handleConfirm() {
    console.log(`${selectedDate} で予約します`);
  }

  window.addEventListener('confirm-reservation', handleConfirm);
  return () => window.removeEventListener('confirm-reservation', handleConfirm);
}, [selectedDate]); // 変わるたびに登録し直す

ただし「日付が変わるたびにイベントリスナーを付け外しするのは避けたい」といった事情がある場合は、useRef に最新値を常に書き込んでおき、リスナー側はそのrefを読む、という方法もあります。

function ReservationForm({ selectedDate }: { selectedDate: string }) {
  const selectedDateRef = useRef(selectedDate);

  useEffect(() => {
    selectedDateRef.current = selectedDate; // 描画のたびに最新値を書き込む
  }, [selectedDate]);

  useEffect(() => {
    function handleConfirm() {
      console.log(`${selectedDateRef.current} で予約します`); // ✅ 常に最新
    }

    window.addEventListener('confirm-reservation', handleConfirm);
    return () => window.removeEventListener('confirm-reservation', handleConfirm);
  }, []); // リスナーの登録は初回だけでよい

  return <DatePicker />;
}

ref.current はクロージャに閉じ込められず、常にその時点の最新値を指します。「リスナーやタイマーの登録自体は初回だけにしたいが、中で使う値は常に最新にしたい」というときの定番パターンです。

exhaustive-depsが警告してくれる理由

ここまでの例はすべて、ReactのESLintルール react-hooks/exhaustive-deps が警告してくれる内容です。このルールは「useEffect の中で使っている外側の変数が、依存配列に入っていない」ことを検出します。

先ほどの useEffect(fn, []) で watchSeconds や selectedDate を使っているコードは、まさにこのルールに引っかかります。警告を安易に無視したりコメントで抑制したりすると、今回のようなstale closureを見逃したまま本番に出てしまいます。警告が出たら「本当にこの値は不要か」「関数型更新やrefで回避できないか」を考える、というのが安全な向き合い方です。

まとめ

「useEffect の中の関数は、実行された瞬間のスコープを写真のように閉じ込める」という感覚を持っておくと、stale closureに出会ったときに原因を素早く特定できます。