【テクニカル・上級編】 useEffectにおける古いクロージャ(Stale Closure)問題 – React実践ガイド

Reactの闇「Stale Closure」を解剖する:なぜあなたのuseEffectは古いStateを掴み続けるのか

Reactで高度なコンポーネントを組み上げているとき、誰もが一度は怪奇現象に遭遇したことがあるはずです。

「ログ出力されたStateが、画面上の表示と食い違っている」
「タイマーが1回だけ進んで、そのまま止まってしまった」
「WebSocketのイベントハンドラが、数分前の古いPropsを参照してデータを破壊した」

デバッガーを当てても、ブレークポイントでは確かに値が存在する。しかし、コールバックの内部に足を踏み入れた瞬間、時間は過去へと逆行する——。

これが、Reactエンジニアの夜を奪う最悪のバグの一つ、Stale Closure(古いクロージャ)問題です。

本稿では、表面的な対処療法(ESLintの警告を無理やり消すような愚行)ではなく、JavaScriptエンジンとReactのレンダリングモデルの根本まで潜り込み、Stale Closureが発生する物理メカニズムと、それをエレガントに撲滅するための設計パターンを徹底解説します。

—

1. クロージャとReactのレンダリングモデルの解剖学

Stale Closureの正体を理解するには、まず「Reactのコンポーネントとは何か」という原点に立ち返る必要があります。

Reactにおいて、関数コンポーネントはレンダリングごとに毎回実行されるただのJavaScript関数です。クラスコンポーネントのようにインスタンスがメモリ上に残り続け、その内部状態が書き換わるわけではありません。

function Counter() {
const [count, setCount] = useState(0);
// …
}

`count` は単なる不変(Immutable)なローカル定数です。`setCount` を呼んで再レンダリングが発生したとき、Reactは `Counter` 関数をもう一度最初から実行します。2回目の実行時には、新しく評価された `count = 1` という「まったく別の変数」が生成されているに過ぎません。

JavaScriptのクロージャが描く「空間の切り出し」

ここでJavaScriptの仕様である「クロージャ(Closure)」が絡み合います。関数が定義されたとき、その関数は外側のスコープにある変数への参照を保持します(語弊を恐れずに言えば、その時点のスコープのレキシカル環境を「キャプチャ」します)。

function Counter() {
const [count, setCount] = useState(0);

// このuseEffectに渡した無名関数は、
// 「このレンダリング回における count」をスコープに閉じ込める
useEffect(() => {
const timer = setInterval(() => {
console.log(`Current count: ${count}`);
}, 1000);

return () => clearInterval(timer);
}, []); // 依存配列が空!
}

上記のコードで何が起きているかをV8等のJavaScriptエンジンのメモリ視点から追ってみましょう。

1. 初回レンダリング(Render 0):

  • `count` は `0`。
  • `useEffect` のコールバック関数(Fn_0)が生成される。Fn_0のクロージャには `count = 0` の変数が紐付いている。
  • 依存配列が `[]` であるため、Reactはこの Fn_0 を実行し、`setInterval` に登録する。

2. ボタン押下等による再レンダリング(Render 1):

  • `setCount(1)` が実行され、再レンダリングが走る。
  • 2回目の `Counter` 関数が実行され、新しい変数 `count = 1` が生まれる。
  • `useEffect` の依存配列は `[]` のまま。比較アルゴリズム(`Object.is`)により「依存配列に変更なし」と判定される。
  • Reactは古いエフェクトのクリーンアップを行わず、Fn_0 をそのまま維持する。

結果どうなるか? 画面上には「1」と表示されているにもかかわらず、`setInterval` 内部で走り続ける Fn_0 は、今やメモリの彼方に消え去るべきだった「Render 0 のスコープ(`count = 0`)」をいつまでも参照し続けることになります。

これが Stale Closure(ゾンビのように生き残った古いクロージャ) の物理的メカニズムです。

—

2. 現場で発生する惨事:タイマー、イベントリスナー、非同期処理

Stale Closureは、単純なカウンターだけでなく、ブラウザAPIや非同期処理と連携する複雑なアプリケーションで本領(=致命的なバグ)を発揮します。

事例A:進まないタイマー

// ❌ 典型的なStale Closureの罠
useEffect(() => {
const id = setInterval(() => {
// 常に Render 0 の count (0) に 1 を足し続けるため、
// 画面の表示は 0 -> 1 になった後、永遠に 1 のまま動かなくなる
setCount(count + 1);
}, 1000);

return () => clearInterval(id);
}, []); // count が依存配列から漏れている!

`setCount(0 + 1)` が毎秒実行され続けるため、Stateは一度 `1` に更新された後、何度コールバックが呼ばれても `1` をセットし続けるという地獄に陥ります。

事例B:WebSocket・EventListenerでの状態破砕

もっと恐ろしいのは、外部のイベントリスナーやWebSocketコールバック内でStateの値を読み取り、それを元に後続処理を行うケースです。

// ❌ イベントハンドラが古い State を参照する
useEffect(() => {
const handleScroll = () => {
// ユーザーの最新のアクセス権限や設定(userSettings)を参照したいのに、
// 初回レンダリング時の初期値(nullやデフォルト値)を参照し続けてしまう
if (userSettings.autoSave) {
saveProgress();
}
};

window.addEventListener(‘scroll’, handleScroll);
return () => window.removeEventListener(‘scroll’, handleScroll);
}, []); // userSettings が漏れている

このバグの性質の悪さは、「テスト環境では一見動いているように見え、本番環境で特定の操作シーケンスを踏んだ時だけ発症する」 という点にあります。

—

3. Stale Closureを徹底破壊する3つのアーキテクチャパターン

では、この問題にどう立ち向かうべきか。依存配列にすべての変数を入れれば解決する……というのは、半分正解で半分間違いです。なぜなら、依存配列に何でも入れると、エフェクトの再実行とクリーンアップが頻発し、タイマーのリセットやWebSocketの切断・再接続といったパフォーマンス不全(あるいは通信障害)を引き起こすからです。

上級エンジニアが使うべき、真の回避パターンを解説します。

—

パターン1:関数型更新(Functional Updates)でスコープへの依存を絶つ

Stateの更新が「現在の最新State」に基づいている場合、そもそもクロージャ経由でStateの変数を参照する必要はありません。`setState` の関数型更新を利用します。

import React, { useState, useEffect } from ‘react’;

export const CorrectTimer: React.FC = () => {
const [count, setCount] = useState(0);

useEffect(() => {
// 関数型更新を使用することで、外部の count 変数に依存しなくなる
const id = setInterval(() => {
setCount((prevCount) => prevCount + 1); // Reactの内部キューから最新のStateが渡される
}, 1000);

// 依存配列が空でも、タイマーは最新のStateを使って正しく加算され続ける
return () => clearInterval(id);
}, []);

return

Timer: {count}

;
};

なぜこれで動くのか?(React内部構造の視点)

`setCount(prev => prev + 1)` を実行した時、Reactは引数の関数を内部のアップデートキュー(Update Queue)に追加します。次にReactが再レンダリングを行う際、キュー内の関数を順番に評価し、最新のStateをその引数へと注入します。

これにより、コールバック関数自身がクロージャ内に `count` の値を保持する必要が一切なくなり、Stale Closureが原理的に発生し得なくなります。

—

パターン2:useRef を用いた「Latest Ref パターン」

「最新の値(StateやProps)を参照したいが、それによって `useEffect` を再実行させたくない(=タイマーのリセットや通信の再切断を防ぎたい)」という要件は、実務で頻繁に発生します。

このとき活躍するのが、可変ミュータブルな参照コンテナとしての `useRef` です。

`useRef` が返すオブジェクトの `.current` プロパティの書き換えは、Reactのレンダリングサイクルとは独立しています。どれだけ再レンダリングが起きても、全レンダリング間で全く同じメモリ空間(参照)が共有されます。

import React, { useState, useEffect, useRef } from ‘react’;

export const LatestRefPatternExample: React.FC = () => {
const [count, setCount] = useState(0);

// 1. 最新のStateを常に保持するための Ref を用意する
const countRef = useRef(count);

// 2. レンダリングのたびに Ref の値を同期的に更新する
countRef.current = count;

useEffect(() => {
const id = setInterval(() => {
// 3. クロージャに閉じ込められた countRef 自体の参照は変わらないが、
// その内部の .current は常に最新の値を指している!
console.log(`最新のカウント値: ${countRef.current}`);
}, 1000);

return () => clearInterval(id);
}, []); // エフェクトは初回のみ実行され、タイマーもリセットされない

return (

Count: {count}

);
};

汎用化:`useLatest` カスタムフック

このパターンは実務で非常に強力なため、以下のような抽象化したカスタムフックとしてライブラリ化しておくのがアーキテクチャ上の定石です。

import { useRef, useLayoutEffect } from ‘react’;

/

  • 渡された値の最新状態を常に保持する Ref を返すフック。
  • レンダリングを発生させずに、コールバック内で最新のProps/Stateを参照したい場合に最適。

/
export function useLatest(value: T): { readonly current: T } {
const ref = useRef(value);

// useLayoutEffect を使うことで、ブラウザ描画の直前に
// 確実に最新の値が Ref に反映されていることを保証する
useLayoutEffect(() => {
ref.current = value;
}, [value]);

return ref;
}

この `useLatest` を使えば、複雑なWebSocketコールバックやイベントハンドラ内でも、依存配列を汚すことなく常に最新のコンテキストにアクセスできます。

—

パターン3:useEffectEvent(React 18以降の将来の標準)

現在ReactチームがRFCとして提案・実験的実装を進めているのが `useEffectEvent`(旧 `useEvent`)です。これはまさにStale Closure問題に対する公式からの最終回答と言えます。

// ⚠️ 将来のReact標準機能(概念実証コード)
import { useState, useEffect, useEffectEvent } from ‘react’;

function ChatRoom({ roomId, theme }) {
const [message, setMessage] = useState(”);

// エフェクトイベントの定義:
// 常に最新の theme を参照できるが、theme が変わってもこの関数自体のアイデンティティは不変
const onConnected = useEffectEvent(() => {
showNotification(‘Connected!’, theme);
});

useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on(‘connected’, () => {
onConnected(); // 最新の theme を使って実行される
});
connection.connect();
return () => connection.disconnect();
}, [roomId]); // theme を依存配列に入れる必要がない!
}

`useEffectEvent` で包まれた関数は、「最新のProps/Stateを常に参照できるクロージャを持ちながら、自身の参照は不変である」 という魔法のような性質を持ちます。これが正式リリースされれば、`useRef` を使った手動の同期パターン(パターン2)の多くを置き換えることになります。

—

4. プロダクションコードを守るためのアーキテクトの戒律

いかに技術的メカニズムを理解していても、人間はミスを犯す生き物です。巨大なチーム開発においてStale Closureの混入を未然に防ぐため、以下のアーキテクチャ規約を導入することを強く推奨します。

1. `eslint-plugin-react-hooks` の厳格化と「無効化コメント」の禁止

`react-hooks/exhaustive-deps` の警告を `// eslint-disable-next-line` で握りつぶす行為は、コードベースに時限爆弾を埋め込むと同義です。

CI/CDパイプラインにおいて、`exhaustive-deps` の警告をエラー(Error)扱いとしてビルドを落ちるように設定してください。「依存配列を正しく書くか」「パターン1や2を使って依存関係そのものを解除するか」の2択しかエンジニアに与えてはなりません。

2. `useEffect` を「状態更新の連鎖」に使わない

Stale Closureが多発するコードの共通点は、`useEffect` の中で別のStateを更新し、それがさらに別の `useEffect` を発火させるという副作用のドミノ倒しが発生していることです。

  • データ同期(外部システムとの連携): `useEffect` を使う
  • ユーザー操作に基づく状態変更: イベントハンドラ(`onClick` 等)で完結させる
  • Stateに基づく計算結果: レンダリング中の計算(`useMemo`)で導出する

この原則を徹底するだけで、`useEffect` の数自体が激減し、結果としてStale Closureに遭遇する確率を極限まで下げることができます。

—

結び

Reactの「関数型レンダリング」という美しい抽象化の裏には、JavaScriptという言語が持つ「クロージャと可変性(Mutablity)」との冷徹なトレードオフが存在します。

Stale Closureは、Reactが壊れているから起きるのではなく、JavaScriptのクロージャが仕様通りに完璧に機能しているからこそ起きる現象です。

1. コンポーネントは毎回新しく実行される単なる関数であること
2. 古いエフェクトは「過去のレンダリングサイクルのスコープ」を抱え込み続けること
3. `useRef` や「関数型更新」は、時間を超越して最新データへアクセスするための架け橋であること

このメカニズムを脳内に完全にメンタルモデルとして構築できたとき、あなたはReactの「魔法」に振り回される一開発者から、フレームワークの挙動を完璧に統御するアーキテクトへと進化を遂げるのです。

コメント

タイトルとURLをコピーしました