本記事は、React の実装で気をつけることとして Stale Closure 問題を取り上げ、解決策の一つとして useRef を用いた記事となります。
対象読者:
- setInterval や setTimeout を用いたタイマー処理で、「なぜか State が更新されない」という現象に直面している人
今回は Polling 処理を題材に扱っていきます。Polling(ポーリング)とは、一定の間隔でサーバーなどへ問い合わせを行う処理を指します。例えばフロントエンドからサーバサイドに一定間隔で API リクエストを飛ばし、ステータスを確認したい場合に Polling 処理を実装します。
React で Polling を実装する場合、setInterval や setTimeout を用いて定期的な処理を実行すると思います。ここで注意が必要なのが「Stale Closure」の問題です。
Stale Closure とは、関数が定義された時点の変数の値を記憶し続けてしまう現象です。通常、クロージャ(外部の変数を参照する関数)は便利な機能ですが、React のような状態管理が頻繁に変わる環境では、「古い値を参照し続けてしまう」という問題を引き起こします。
クロージャの概要
まず JavaScript のクロージャとはなんなのか簡単に説明します。
function outer() {
const message = "Hello";
function inner() {
console.log(message); // 外側のmessageを参照できる
}
return inner;
}
const myFunc = outer();
myFunc(); // "Hello" と表示される
このように、関数(inner)は定義された時点のスコープ(外側の変数)を記憶します。これがクロージャです。
Stale Closure 問題は、この「記憶」が裏目に出るケースです。
タイマー処理における Stale Closure 問題
Stale Closure は、特にタイマー処理を伴うシナリオで発生します。
setIntervalやsetTimeoutに渡した関数が内部で参照している変数の値は、関数定義時点の値に固定され、その後 state が更新されても関数内では古い値のままになります。例を示します。
タイマー処理を伴うシナリオ例:
import { useState, useEffect } from "react";
function StalePolling() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
// ⚠ Stale Closureが発生
// この関数が生成された時の count(初期値の0)を記憶し続けている
console.log(`Polling時のcount: ${count}`);
// countは常に「0」なので、毎回「0 + 1」をセットしようとする
setCount(count + 1);
}, 1000);
return () => clearInterval(timer);
}, []); // 依存配列が空なので、初回マウント時のみタイマーを設定する
return (
<div>
<p>Count: {count}</p>
{/* 期待:ボタンを押せば、そこからさらにカウントアップしてほしい */}
<button onClick={() => setCount((c) => c + 10)}>一気に10増やす</button>
<p>※1秒経つと、カウントが 1 に引き戻されてしまいます</p>
</div>
);
}
useEffect の依存配列に注目すると、空を指定しています。仮に依存配列に[count]を入れると count が変わるたびに setInterval が再設定されてしまい、Polling 処理が意図せず何度もリセットされてしまうためです。ところが useEffect の依存配列が空の場合、初回レンダリング以降は中の関数は再生成されず古い値を参照し続けます。
つまり上記のように setInterval に実行関数を渡してしまうと、その関数は「作成された時点の状態」をスナップショットとして固定してしまいます。その後、いくら state の値が更新されても、実行される関数の中では「一番最初に実行関数を渡したタイミングの値」を参照し続けてしまいます。結果的に count の値は 1 から増えず、手動で更新しても元の値に引き戻されてしまいます。
useRef を用いた解決法
上記問題は useRef を使うことで解決できます。
import { useState, useEffect, useRef } from "react";
export default function CorrectPolling() {
const [count, setCount] = useState(0);
// 1. 最新の値を保持するための「箱」を用意する
const countRef = useRef(count);
// 2. countが変わるたびに、箱の中身を最新に入れ替える
useEffect(() => {
countRef.current = count;
}, [count]);
useEffect(() => {
const timer = setInterval(() => {
// 3. 常に最新の countRef.current を取得できる!
console.log(`Polling count: ${countRef.current}`);
// setInterval の中で最新の Ref の値を使って更新する
setCount(countRef.current + 1);
}, 1000);
return () => clearInterval(timer);
}, []);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount((c) => c + 1)}>Increment</button>
</div>
);
}
実装のポイント
この解決策の肝は、役割に応じて useEffect を 2 つに分離している点です。
- 1 つ目の useEffect(依存配列:
[count]):
countが更新されるたびに、refに最新の値を同期させます。 - 2 つ目の useEffect(依存配列:
[]):
setIntervalを初回マウント時のみ設定します。
この分離により、「タイマーの設定は一度きり」にしつつ、「実行時には常に最新の値を参照できる」という両立を実現しています。
なぜ useRef なのか?
ここで useState ではなく useRef を採用している理由は、再レンダリングを発生させずに値を保持したいからです。
useState との違い
-
useState: 値が変わると再レンダリングが発生しない -
useRef: 値が変わっても再レンダリングは発生しない(値は保持され続ける)
useRef の仕組み
-
useRefは{ current: ... }というオブジェクト(箱)を返す - このオブジェクト自体はレンダリングを跨いでも同じ参照を保ち続ける
-
countRef.current(箱の中身)を書き換えても React はそれを検知しないため、コンポーネントの再レンダリングやuseEffectの再実行(タイマーのリセット)は起こらない
つまり、setInterval 内の関数から「常に同じ箱(ref)」を見に行かせることで、タイマーを止めることなく、裏側でこっそりと最新の値を受け渡すことができます。
以上が useRef を使った解決法となります。
おまけ:カスタムフックによる抽象化
useRef を使って改良した結果、正しく動作するようになりましたが、以下の点で可読性に課題があると思います。
- Polling 処理を使うたびに同じパターンの useRef と useEffect を書く必要がある
- Stale Closure 問題を理解していない開発者には仕組みが分かりにくい
この問題を解決するため、Dan Abramov 氏の記事ではカスタムフックで抽象化し呼び出す方法が紹介されています。また、useRef についてもより詳しく解説しています。
カスタムフックのコード例(上記記事より):
https://codesandbox.io/p/sandbox/xvyl15375w?file=%2Fsrc%2Findex.js
まとめ
- Stale Closure 問題は、関数が定義時の古い値を参照し続ける現象
- Stale Closure 問題は、タイマー処理を伴うシナリオでよく発生する
- useRef による解決策:
-
useRefでレンダリングを跨いで最新値を保持する「箱」を作成 - 別の
useEffectで状態更新時にref.currentへ最新値を代入 - 実行関数内では
ref.currentを参照することで常に最新値を取得
-