こんにちは。毎日フロントエンドのコードと向き合い、時には不可解なバグに頭を抱え、時には洗練されたアーキテクチャに酔いしれる日々を送っていることでしょう。
さて、今日は中級へのステップアップを狙う君たちが必ずと言っていいほどハマる、そして実務の現場で頭を悩ませる「ステイルクロージャ(Stale Closure:古いクロージャ)」について話をしよう。
「あれ? ボタンを連打したのに、なぜかstateが古いまま更新されていく……」
「useEffectの中で最新のstateを参照したいのに、初期値のまま固定されている……」
もし君がこの現象に直面し、原因が分からずに `console.log` を大量に埋め込む羽目になったとしたら、それはReactのクロージャの仕組みと、ブラウザのJavaScriptの実行モデルに対する理解を少しだけアップデートする絶好のタイミングだ。
今日は、この厄介なステイルクロージャの正体を暴き、現場で即座に使える具体的な回避策を徹底的に叩き込んでいく。コーヒーでも飲みながら、じっくりとついてきてほしい。
—
1. ステイルクロージャとは何か?(なぜ「古い状態」を掴んでしまうのか)
まず、敵を知ることから始めよう。ステイルクロージャとは文字通り、「古くなったスコープ(クロージャ)内の変数をいつまでも参照し続けている状態」のことだ。
Reactの関数コンポーネントは、レンダリングが走るたびに関数全体が上から下へと実行される。この時、コンポーネント内で定義された関数(イベントハンドラーや非同期処理のコールバックなど)は、その「特定のリトライ(レンダリング)時点」のスコープにある変数を閉じ込めて(クローズして)生成される。
もし、その関数が生成された後にstateが更新され、新しいコンポーネントの実行(新しいスコープ)が起きたとしても、すでに生成されてしまった古い関数は、過去のスコープにある古いstateの値を指し続けようとする。 これがステイルクロージャの正体だ。
ブラウザの裏側では、V8などのJavaScriptエンジンがメモリ管理とガベージコレクションを行っているが、関数がスコープ内の変数を参照している限り、そのメモリ領域は保持され続ける。ここにReactの非同期な状態更新が絡むことで、バグの温床が完成する。
—
2. 現場でよくある失敗パターン
百聞は一見に如かず。よくある「やってしまいがち」なコードを見てみよう。3秒間カウントダウンしながら数値をインクリメントするような非同期処理を想像してほしい。
import React, { useState } from ‘react’;
export const BadCounter: React.FC = () => {
const [count, setCount] = useState(0);
const handleAsyncIncrement = () => {
// 3秒後にカウントを増やす意図の非同期処理
setTimeout(() => {
// ここがステイルクロージャの罠!
// この関数が生成された瞬間の `count`(例えば 0)をずっと保持している
setCount(count + 1);
}, 3000);
};
return (
Count: {count}
{/ 3秒の間にボタンを2回連打すると……? /}
);
};
何が起きるか?
ユーザーが3秒の間にボタンを2回連打したとする。最初のクリックで `count` は `0`、2回目のクリックでも `count` は `0` の世界で関数が作られる。3秒後、最初のタイマーが発火して `setCount(0 + 1)` が走り、その直後にもう一つのタイマーが発火して `setCount(0 + 1)` が走る。
結果として、2回押したのに `count` は `2` にならず、`1` のままになってしまう。これが実務で最も恐ろしい、サイレントなバグの正体だ。
—
3. ステイルクロージャを華麗に回避する3つのアプローチ
この問題に対して、シニアエンジニアとしてチームに提案できるベストプラクティスは主に3つある。状況に応じて使い分けられるようにしよう。
アプローチ A:関数型更新(Functional Update)を使う
最もシンプルで、stateの更新だけに起因する問題の9割はこれで解決する。
`setCount` に直接値を渡すのではなく、「最新のstateを引数に取り、新しいstateを返す関数」を渡す方法だ。これによって、Reactの内部で常に最新のstateが担保された状態で計算が行われる。
import React, { useState } from ‘react’;
export const GoodCounterA: React.FC = () => {
const [count, setCount] = useState(0);
const handleAsyncIncrement = () => {
setTimeout(() => {
// 引数(prevCount)には、Reactがその瞬間に保持している最新のstateが入る
setCount((prevCount) => prevCount + 1);
}, 3000);
};
return (
Count: {count}
{/ 連打してもちゃんと押した回数分だけカウントアップされる /}
);
};
アプローチ B:`useRef` で最新の値を保持する
「いや、stateの更新だけじゃなくて、非同期のコールバック内で現在の最新の値を何かに使いたいんだ」というケースもある。例えば、WebSocketの受信ロジックや、複雑なアニメーションのコールバックなどだ。
こういうときは、「レンダリングをトリガーしないが、常に最新の値を書き換えて保持できる箱」である `useRef` を使おう。
import React, { useState, useEffect, useRef } from ‘react’;
export const GoodCounterB: React.FC = () => {
const [count, setCount] = useState(0);
// 最新のcountを保持するためのref
const countRef = useRef(count);
// countが変化するたびに、refの値を最新にアップデートする
useEffect(() => {
countRef.value = count; // ※TypeScriptの型や書き方に注意(通常は countRef.current = count)
}, [count]);
// 正しい書き方(currentを使う)
const latestCountRef = useRef(count);
useEffect(() => {
latestCountRef.current = count;
}, [count]);
const handleCheckLatest = () => {
setTimeout(() => {
// ref経由であれば、タイマー発火時点での最新の値を確実に取得できる
alert(`最新のカウントはこれだ: ${latestCountRef.current}`);
}, 3000);
};
return (
Count: {count}
);
};
※プロからのワンポイントアドバイス: `useRef` は「Reactのデータフローから外れた逃げ道」でもある。多用しすぎるとコードの予測可能性が下がるため、あくまで「クロージャで最新値をキャプチャし続ける必要がある外部連携やタイマー処理」に限定して使うのがスマートだ。
アプローチ C:`useEffect` の依存配列(Dependency Array)を正しく設定する
もし問題が「カスタムフックやコンポーネント内のエフェクト(`useEffect`)」で起きているなら、原因はほぼ間違いなく「依存配列の記述漏れ」だ。
Reactは、依存配列に含まれる値が変化した時だけ、エフェクト(中の関数)をクリーンアップして再生成する。ここに変数を入れ忘れると、エフェクトの中の関数は「初回レンダリング時の古いスコープ」を閉じ込めたままになり、外側の変数が変わっても古い値を参照し続けることになる。
import React, { useState, useEffect } from ‘react’;
export const GoodEffect: React.FC<{ userId: string }> = ({ userId }) => {
const [data, setData] = useState(null);
useEffect(() => {
// userIdが変化するたびに、このエフェクトは再実行され、
// 常に最新のuserIdを使ったフェッチ処理が走る。
const fetchUserData = async () => {
const response = await fetch(`/api/user/${userId}`);
const result = await response.json();
setData(result);
};
fetchUserData();
// 依存配列に userId を入れ忘れると、propsが変わっても古いuserIdでフェッチし続けるステイルクロージャになる!
}, [userId]);
return
;
};
ESLintの `react-hooks/exhaustive-deps` ルールを有効にしていれば、この手のミスはビルド時やエディタ上で即座に検知できる。絶対にルールをオフにしたり、警告を無視したりしないこと。
—
4. シニアからのまとめ
ステイルクロージャは、Reactという宣言的UIライブラリが「JavaScriptのクロージャという手続き型の言語機能」の上になり立っている以上、避けて通れない宿命だ。
しかし、恐れる必要はない。基本原則さえ押さえておけば怖くない。
1. stateの更新には、極力「関数型更新(`setX(prev => …)`)」を使う。
2. 非同期コールバック等で最新のstateをどうしても参照したい場合は、`useRef` をバッファとして活用する。
3. `useEffect` や `useCallback` の依存配列は、サボらずに正確に記述し、ESLintの警告に耳を傾ける。
この3つをチームの共通認識として持てるようになれば、君の書くReactコードの品質は一段も二段も跳ね上がるはずだ。さあ、明日からの開発で早速コードを見直してみよう!

コメント