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

やあ、現場で戦うフロントエンドエンジニアの諸君。今日もコンポーネントの再レンダリングや、意図しない副作用と格闘していることだろう。

今日は、Reactを触り始めてしばらく経った中級者が必ずと言っていいほどぶつかり、そして多くのシニアエンジニアさえも時に足元をすくわれる「古いクロージャ(Stale Closure)」の問題について語ろう。

「`useEffect`の中で参照しているステートが、なぜか古いままなんだ」
「依存配列(Dependency Array)にステートを入れると、エフェクトが何度も走りすぎて無限ループになる……。だから外したんだけど、そうすると値が更新されない」

こんな経験はないかな? これはReactのバグではない。JavaScriptの「クロージャ」という言語仕様と、Reactの「レンダリング・スナップショット」という設計思想が交差する場所で起きる、極めて論理的な現象なんだ。

さあ、この「幽霊」の正体を暴き、エレガントに解決する方法を伝授しよう。

—

1. なぜ「古い」値が残るのか? —— スナップショットの罠

まず理解してほしいのは、Reactのレンダリングは「その時点でのスナップショット」であるということだ。

関数コンポーネントが実行されるたび、その中にある変数や関数(`useEffect`に渡したコールバックも含む)は、そのレンダリング時のステートを閉じ込めた(クロージャに収めた)新しい実体として生成される。

仕組みを解剖する

1. `count` が `0` の時にレンダリングされる。
2. `useEffect` が実行され、その中の関数が「`count` は `0` だ」と記憶する。
3. 何らかの理由で `count` が `1` に更新され、再レンダリングが走る。
4. しかし、もし `useEffect` の依存配列が空 `[]` だったら?
5. Reactは「最初のエフェクトを再利用する」と判断する。そのエフェクトが持っている `count` は、永遠に `0` のまま固定されたスナップショットなんだ。これが Stale Closure の正体だ。

—

2. 現場でよく見る「動かないタイマー」

具体的なコードを見てみよう。毎秒カウントアップしたいだけなのに、なぜか `1` から増えない……という典型的なバグだ。

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

export const StaleCounter = () => {
const [count, setCount] = useState(0);

useEffect(() => {
const timer = setInterval(() => {
// 😱 ここが Stale Closure の現場だ!
// この関数が生成された時の count (0) をずっと参照し続ける
console.log(`Current count in interval: ${count}`);
setCount(count + 1);
}, 1000);

return () => clearInterval(timer);

// 依存配列を空にすると、この Effect はマウント時の「count=0」を掴んだまま離さない
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

return (

Count: {count}

コンソールを見てごらん。ずっと 0 のままだろう?

);
};

ブラウザの裏側では、`setInterval` に渡された関数が「1秒ごとに `0 + 1` を実行せよ」という命令を忠実に繰り返しているだけだ。`count` は変わっているはずなのに、この関数が見ている `count` は、生まれた瞬間の姿(0)のまま時間が止まっている。

—

3. プロが選ぶ解決策その1:関数型更新(Functional Updates)

もっとも推奨される、そしてもっともクリーンな解決策は、`useState` の関数型更新を使うことだ。

`setCount(count + 1)` と書くのではなく、`setCount(prev => prev + 1)` と書く。これだけで、クロージャが古い値を持っていようが関係なくなる。React内部が保持している「最新の状態」を引数で受け取れるからだ。

useEffect(() => {
const timer = setInterval(() => {
// 最新の値を引数 `prev` として受け取るため、
// クロージャが古い count を持っていても問題なく更新できる
setCount(prev => prev + 1);
}, 1000);

return () => clearInterval(timer);
}, []); // 依存配列に count を入れる必要がなくなる!

これの美しいところは、「依存配列を増やさなくて済む」点にある。依存配列を増やすとエフェクトの再実行(タイマーの破棄と再設定)が走るが、関数型更新ならタイマーを維持したまま中身だけを最新に保てる。

—

4. プロが選ぶ解決策その2:useRef を「脱出ハッチ」として使う

時には、ステートを更新するだけでなく、副作用の中で最新の値を「参照」だけしたい場合もあるだろう。そんな時は `useRef` の出番だ。

`useRef` が返すオブジェクトは、全レンダリングを通して同一の参照(同一のメモリ上の箱)を保持する。箱の中身(`.current`)を書き換えても再レンダリングは発生しないが、どの時点のクロージャからも同じ「最新の箱」を見ることができる。

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

export const RefExample = () => {
const [count, setCount] = useState(0);

// 最新の値を保持するための「箱」
const countRef = useRef(count);

// レンダリングのたびに、Refの中身を最新のステートで同期する
useEffect(() => {
countRef.current = count;
});

useEffect(() => {
const timer = setInterval(() => {
// countRef.current は常に最新の値を指しているため、
// 依存配列を空にしても最新の値にアクセスできる
console.log(`Latest count via Ref: ${countRef.current}`);
}, 1000);

return () => clearInterval(timer);
}, []); // 依存配列は空でOK

return (

Count: {count}

);
};

これは「Reactの宣言的なデータフロー」からは少し外れる、いわば “脱出ハッチ(Escape Hatch)” だ。多用は禁物だが、外部ライブラリとの連携や、複雑なイベントリスナーの制御には極めて強力な武器になる。

—

5. アーキテクトからのアドバイス:linterを無視するな

最後に、一番重要なことを伝えよう。

多くのエンジニアが Stale Closure を引き起こす際、`eslint-plugin-react-hooks` が出す警告を `// eslint-disable-next` で黙らせているのを見かける。これは絶対にやめるべきだ。

警告が出るということは、君のコードが「JavaScriptのクロージャの性質」と「Reactの期待する動作」の板挟みになっている証拠だ。

1. 依存配列には正直に書く。
2. 再実行を避けたければ、関数型更新や `useRef` を検討する。
3. 副作用のロジックが複雑すぎるなら、カスタムHookに切り出す。

このステップをサボらなければ、Stale Closure は恐れるに足りない。

Reactの「スナップショット」という概念を愛せるようになれば、君のフロントエンドエンジニアとしてのスキルは一段上のステージへ到達するだろう。泥臭いバグ修正の裏側にある、論理的な美しさを楽しんでくれ。

健闘を祈る。

コメント

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