【ITニュース解説】Common Stale Closure Bugs in React
2025年09月20日に「Dev.to」が公開したITニュース「Common Stale Closure Bugs in React」について初心者にもわかりやすく解説しています。
ITニュース概要
Reactで古い状態(stateやprops)を関数が参照し続ける「stale closure」バグは、setIntervalや非同期処理、イベントリスナーでよく発生する。解決には、関数型setStateや依存配列への変数追加、最新値を参照(ref)する方法が有効だ。
ITニュース解説
Reactアプリケーションを開発していると、「Stale Closure(ステイルクロージャ)」という現象に遭遇することがある。これは、ある関数が、それが作られた時点の古い変数の値を記憶してしまい、最新の値にアクセスできないために意図しない動作を引き起こすバグである。特にReactのフック(useState、useEffectなど)とJavaScriptの「クロージャ」という仕組みが組み合わさることで発生しやすい。システムエンジニアを目指す上では、この概念と対策を理解しておくことが重要である。
「クロージャ」とは、関数がその関数が定義された環境(スコープ)にある変数を記憶し、その関数がスコープ外で実行されてもその変数にアクセスできる仕組みを指す。Reactでは、コンポーネントが再レンダリングされるたびに、そのコンポーネント内の関数が新しく作成されるように見える。しかし、useEffectなどのフックの内部で作成された関数が、依存配列([]の中に記述する変数リスト)に正しくない値が指定されていると、初回レンダリング時の古い値を捕らえたままになってしまうことがある。これがStale Closureの主な原因である。
具体的なStale Closureの発生例とその対策をいくつか見ていこう。
一つ目は、setIntervalで古いstateの値を使用してしまうケースである。例えば、countというstateを持つカウンターをsetIntervalで1秒ごとに増やそうとする場合を考える。useEffectフック内でsetIntervalを設定し、その依存配列を空の[]にすると、useEffectはコンポーネントが最初にマウントされた時(ページに表示された時)に一度だけ実行される。このとき、setIntervalのコールバック関数は、初回レンダリング時のcountの値(通常は0)を記憶してしまう。そのため、setCount(count + 1)と記述しても、countは常に0として扱われ、結果としてカウンターは0から1にしか増えないという問題が発生する。
この問題を解決する最も推奨される方法は、「関数型state更新」を利用することである。setCount(c => c + 1)のように記述すると、cには常にその時点での最新のcountの値が自動的に渡されるため、古い値を参照する心配がなくなる。この方法では、useEffectの依存配列にcountを含める必要もない。別の解決策として、useEffectの依存配列にcountを含めることもできるが、この場合、countが変わるたびにsetIntervalが一度クリアされ、新しいcountの値で再設定されるため、処理の効率が悪くなる可能性がある。
二つ目は、非同期関数が古いpropsやstateを読み込んでしまうケースである。例えば、検索フォームでsearchTermというpropsを受け取り、そのsearchTermを使ってAPIから検索結果を取得するコンポーネントを考える。useEffect内で非同期関数を使ってAPI呼び出しを行う場合、もしuseEffectの依存配列にsearchTermを含めないと、searchTermが変更されてもuseEffectが再実行されないため、API呼び出しは常に初回レンダリング時に記憶した古いsearchTermで行われてしまう。
この問題を解決するには、useEffectの依存配列にsearchTermを明示的に含めることが必要である。これにより、searchTermが変更されるたびにuseEffectが再実行され、最新のsearchTermを使ったAPI呼び出しが行われるようになる。さらに、非同期処理においては、ユーザーが連続して検索語を変更した場合などに、古い検索リクエストの結果が新しい検索リクエストの結果を上書きしてしまう「競合状態(レースコンディション)」が発生する可能性がある。これを避けるためには、API呼び出しをキャンセルする仕組み(例えば、処理中にcancelledフラグを立てて、処理完了時にcancelledがtrueであればstateを更新しないようにする)を導入することが望ましい。
三つ目は、イベントリスナーが古いstateやpropsを保持してしまうケースである。例えば、キーボードの矢印キーでvalueを増減させるようなグローバルなイベントリスナーをwindowオブジェクトに追加する場合を考える。useEffect内でwindow.addEventListenerを使ってイベントリスナーを一度だけ登録し、その依存配列を空の[]にしたとする。このイベントリスナー内の関数がsetValue(value + 1)のようにvalueを直接参照していると、初回レンダリング時のvalue(通常は0)を記憶してしまい、valueが増えることなく常に0から1にしか増えない。
ここでも、一つ目のケースと同様に、「関数型state更新」が有効な解決策である。setValue(v => v + 1)のように記述することで、vには常に最新のvalueが渡されるため、イベントリスナーは常に正しい値に基づいてstateを更新できる。この場合、依存配列にvalueを含める必要はない。別の方法として、useRefフックを利用する「refパターン」もある。useRefを使ってvalueの最新値を常に参照するvalueRefを作成し、valueRef.current = valueとすることで、refが常に最新のvalueを保持するようにする。イベントリスナー内ではvalueRef.currentを参照することで、古い値にアクセスする問題を回避できる。このuseRefを使ったパターンは、イベントリスナー自体を再生成することなく安定して動作させたい場合に特に有効である。
四つ目は、throttled(スロットル)やdebounced(デバウンス)されたハンドラーがStale Closureを持つケースである。throttleやdebounceは、スクロールイベントのように頻繁に発生するイベントの処理回数を制限するために使われるテクニックである。例えば、useMemoフックを使って一度だけthrottle関数を生成し、その内部でstateを参照する場合、useMemoの依存配列が空であれば、throttle関数内部のコールバックは初回レンダリング時の古いstateの値を参照し続けてしまう可能性がある。
この問題を解決する一つの方法は、スロットル/デバウンスされたコールバック内でstateを直接読み取らず、イベントから直接値を得るようにすることである。例えば、スクロールイベントから現在のスクロール位置を直接取得してstateを更新する場合には、Stale Closureの問題は発生しない。しかし、もしstateの値を参照する必要がある場合は、前述の「refパターン」が有効である。useRefを使ってcountの最新値を常に参照するcountRefを作成し、useEffectでcountRef.current = countとすることで、refを最新のcountに同期させる。throttle関数内部のコールバックではcountRef.currentを参照することで、常に最新のcountにアクセスできる。
Stale Closureを避けるための共通の対策は以下の通りである。まず、「関数型state更新」を積極的に利用すること。setState(prev => compute(prev))の形式を使えば、prevには常に最新のstate値が渡されるため、古い値を参照する心配がほとんどなくなる。次に、useEffectやuseCallbackなどのフックを使用する際には、そのフックの内部で参照する外部の変数(state、props、コンポーネントスコープで定義された関数など)はすべて依存配列に含めること。これにより、依存する値が変更されたときにフックが再実行され、最新の値が使われるようになる。もし、依存配列に含めたくない、あるいは含めるとフックが頻繁に再実行されてしまうなどの理由で、安定したコールバックが必要な場合は、「refパターン」を用いてuseRefに最新の値を保持させ、そこから参照することも有効な手段である。また、setIntervalやaddEventListenerのようなタイマーやイベントリスナーは、依存する値が変わったら適切にクリーンアップ(clearIntervalやremoveEventListenerなど)して再設定することを忘れてはならない。throttleやdebounceを使用する関数においては、可能な限りイベント引数から直接必要な値を導出するか、やはりuseRefを使って最新のstateを参照する。
これらの対策を理解し実践することで、ReactアプリケーションにおけるStale Closureのバグを効果的に防ぎ、より堅牢で予測可能なコードを書くことができるようになるだろう。