Webエンジニア向けプログラミング解説動画をYouTubeで配信中!
▶ チャンネル登録はこちら

【ITニュース解説】Updating But Not Reflecting!? React’s Common State 'Stale Closure' Pitfall

2025年09月13日に「Dev.to」が公開したITニュース「Updating But Not Reflecting!? React’s Common State 'Stale Closure' Pitfall」について初心者にもわかりやすく解説しています。

作成日: 更新日:

ITニュース概要

Reactでstateを更新しても古い値が参照されるのは、「Stale Closure」が原因だ。関数が作られた時の古いデータを記憶し続けるため、最新の値が使われない。`useEffect`の依存配列設定、関数型setState、`useRef`を活用することで、常に最新のデータを扱える。

ITニュース解説

Reactを使った開発で、「更新したはずなのに、なぜか古い値が表示される」「値が変わらない」といった不思議な現象に遭遇した経験はないだろうか。例えば、ボタンをクリックして状態を更新しても、ある関数の中ではなぜか初期値のままだったり、一定間隔で実行される処理の中で常に同じ古い値を参照してしまったりといった状況だ。この一見不可解な動作の背景には、「Stale Closure(ステイルクロージャ)」という問題が潜んでいる。

この問題を理解するためには、まずJavaScriptにおける「スコープ」と「クロージャ」という基本的な概念から見ていこう。スコープとは、簡単に言えば「変数がどこまで有効な範囲か」を定めたものだ。例えば、ある関数の中で定義された変数は、その関数の外からは原則としてアクセスできない。これは、変数がそれぞれの「居場所」を持っているイメージに近い。

次に、クロージャとは、関数が「それが作られた時の環境(スコープ)を記憶している」という仕組みを指す。通常、関数がその実行を終えると、その中で使われていた変数は消滅するはずだ。しかし、クロージャのおかげで、関数が作られた時のスコープにある変数に、後からでもアクセスできるようになる。たとえ外側の関数が実行を終えても、内側の関数がその環境を「冷凍保存」しているようなものだ。

さて、なぜこのクロージャの仕組みがReactで問題を引き起こすのか。Reactコンポーネントは、JavaScriptの関数として書かれている。そして、Reactのコンポーネントが再レンダリングされるたびに、そのコンポーネント全体が新しい関数として実行され、そのたびに新しいスコープが生成される。

Stale Closureが具体的に現れるのは、ReactのuseEffectフックなどで、依存配列を空[]にして副作用関数(エフェクト)を一度だけ実行するように設定した場合だ。このとき、useEffectのコールバック関数は、コンポーネントが最初にレンダリングされた時点のスコープを記憶したクロージャとして作成される。つまり、そのクロージャの中では、コンポーネントの状態が後からいくら更新されても、最初に見た「古い値」をずっと参照し続けてしまうのだ。

具体例で考えてみよう。ボタンをクリックするとcountという状態変数が1ずつ増えるカウンターコンポーネントがあるとする。そして、useEffectを使って、1秒ごとに現在のcountの値をコンソールに出力する処理をsetIntervalで設定したとする。このuseEffectの依存配列が空の場合、setIntervalのコールバック関数は、コンポーネントがマウントされた時点のcount(例えば初期値の0)を記憶したクロージャとなる。結果として、ボタンをいくらクリックしてcountが更新されても、setIntervalの中では永遠にcount: 0が出力され続けることになる。これがStale Closure、つまり「古いスコープに閉じ込められた」状態だ。

このようなStale Closureは、setIntervalsetTimeoutのコールバックだけでなく、外部のイベントハンドラ(WebSocketからのメッセージ受信やaddEventListenerで登録された関数)、あるいはfetchaxiosなどの非同期処理の後に実行されるコールバック(then句やasync/awaitで待機した後の処理)が、コンポーネントの状態を読み取ろうとするときによく発生する。これらの共通点は、一度登録された関数(クロージャ)が、コンポーネントのライフサイクルとは独立して長時間生き続けることにある。

では、このStale Closureの問題をどのように解決すれば良いのだろうか。主に三つの方法が挙げられる。

一つ目は、useEffect依存配列を正しく指定する方法だ。もし、useEffectの内部で特定の状態変数(上記の例ではcount)を利用しているのであれば、その変数を依存配列に含めるべきだ。[count]のように指定することで、countが変更されるたびにuseEffectが再実行され、その都度、最新のcountの値を記憶した新しいクロージャが作成される。これにより、setIntervalの中のcountも常に最新の値となる。しかし、状態が頻繁に更新される場合、useEffectの再実行やクリーンアップ処理(clearIntervalなど)が頻繁に行われることになり、パフォーマンスに影響を与えたり、リソース管理が複雑になったりする可能性もあるため注意が必要だ。

二つ目は、状態を更新する際に、関数形式のsetStateを利用する方法だ。setCount(count + 1)のように直接値を渡すのではなく、setCount(prevCount => prevCount + 1)のように、一つ前の状態を受け取って新しい状態を返す関数を渡す形だ。この関数形式のsetStateは、Reactが常に最新の状態値を引数として提供してくれるため、クロージャが古いcountの値を記憶しているかどうかに関わらず、常に最新の状態に基づいた更新を行うことができる。これは非常に安全で、Stale Closureの問題を根本的に回避できる優れたパターンである。

三つ目の、そして特に強力な解決策は、Reactの**useRefフックを活用する**方法だ。useRefは、レンダリングをまたいで値を永続的に保持できる「箱」のようなものだと考えると分かりやすい。このuseRefが返すオブジェクトのcurrentプロパティに値を格納し、その値を読み書きできる。useRefの大きな特徴は、currentプロパティの値を更新しても、コンポーネントの再レンダリングがトリガーされない点にある。これは、DOM要素への直接的な参照だけでなく、コンポーネントの再レンダリングに影響を与えずに、任意の可変値を保持したい場合に非常に便利だ。

Stale Closureの問題をuseRefで解決する具体的な方法を見てみよう。まず、useRefを使ってcountの値をミラーリングする変数(例えばcountRef)を作成する。次に、useEffectを使ってcountが更新されるたびに、countRef.currentに最新のcountの値を代入するように設定する。このuseEffectの依存配列には[count]を含めることで、countが変わるたびにcountRef.currentも更新される。そして、Stale Closureが発生していたsetIntervalなどのコールバック関数の中では、直接countを参照する代わりにcountRef.currentを参照するようにする。こうすることで、setIntervalのコールバックは常にcountRefが保持している最新のcountの値にアクセスできるようになるのだ。

さらに高度な使い方として、関数自体をuseRefに格納することも可能だ。例えば、最新の状態値に依存する複雑なロジックを持つ関数がある場合、その関数をuseRef.currentに代入するようにuseEffectで設定し、他の場所からはref.current()として呼び出すことで、常に最新のロジックを実行できる。

まとめると、クロージャは関数が作られた時のスコープを記憶するJavaScriptの強力な機能だが、ReactにおいてuseEffectの依存配列が不適切だったり、一度登録された関数が長期間生き残る場合に、古い状態を参照し続けてしまうStale Closureという問題を引き起こす。この「更新しているのに値が変わらない」という混乱を招く現象は、解決策としてuseEffectの依存配列を適切に指定すること、状態更新に関数形式のsetStateを用いること、そしてレンダリングを跨いで最新の値を永続的に保持できるuseRefを巧みに活用することなどが挙げられる。これらの手法を理解し適切に使いこなすことで、Reactアプリケーションの安定性と予測可能性を高めることができるだろう。

関連コンテンツ