useEffectの依存配列とクロージャの罠を実例で解説
前回の記事「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(fn, [])のfnは、マウント時点のスコープを閉じ込めたクロージャとして1度だけ作られる- 依存配列が空だと、その後stateが更新されても
fnの中の変数は古い値のまま参照され続ける(stale closure) setIntervalのコールバックが増えない、イベントリスナーが古いpropsを見る、といった症状はこの典型例- 直し方は主に2つ
- 依存配列に値を追加して、値が変わるたびに実行し直す
setState(prev => ...)の関数型更新や、useRefで「最新値を読む」経路を作り、クロージャに古い値を持たせない
react-hooks/exhaustive-depsの警告は、stale closureを未然に防ぐための重要なシグナル
「useEffect の中の関数は、実行された瞬間のスコープを写真のように閉じ込める」という感覚を持っておくと、stale closureに出会ったときに原因を素早く特定できます。