概要
useEffectの依存配列とcleanupは「リソースのライフサイクル管理」そのもの
依存配列を省略した実装は一見動いてしまうためバグに気づきにくい。cleanup関数とセットで「Effectが管理するタイマー・購読・インフライトリクエストのライフサイクルを、コンポーネント自身のライフサイクルと一致させる」ことがuseEffectの本質。
クロージャ内のstate直接参照は「古い値」を掴んだままになる
setIntervalやイベントリスナーのように「Effectの外側から非同期に呼ばれる関数」の中では、クロージャが古いレンダー時点のstateを参照し続ける典型的な罠(stale closure)になる。関数型更新(prev => ...)が対策。
派生できる値をstateとして複製すると「複数の真実の情報源」が生まれる
isOverThresholdはstatsとalertThresholdから100%計算できる値。単一の情報源から都度計算すれば、更新のタイミングずれ自体が原理的に発生しなくなる。
非同期処理の「発行順序」と「解決順序」は一致しない
ネットワーク遅延でレスポンスの到着順が逆転しうる。「一番最後に発行したリクエストの応答だけを信頼する」ガードを明示的に設計しないと、古いデータが新しいデータを上書きするrace conditionが起きる。
問題
MOps 販促システムチームの社内管理画面(Next.js 15 App Router)には、セール中にキャンペーンの注文数・売上・クーポン使用数を3秒おきにポーリングして表示する CampaignLiveWidget というクライアントコンポーネントがある。閾値(デフォルト1000件)を注文数が超えるとAlertを表示し、下位のStatsChart(グラフ描画コストが数百msかかる重いコンポーネント)はReact.memoでラップして不要な再描画を防ぐ設計になっているはずだった。
- 「ダッシュボードのタブを開きっぱなしにしていると、時間が経つほどブラウザが重くなる」
- 「注文数が閾値を超えてからアラートが出るまで、体感で1テンポ遅れる」
- 「セール中、想定より明らかに多い回数
/api/campaigns/{id}/live-statsへのリクエストが飛んでいる(バックエンド側のログで判明)」
制約・前提条件
- Next.js 15 App Router、
"use client"指定のクライアントコンポーネント StatsChartはReact.memoでラップ済みで、propsが変わらない限り再描画しない設計のはずだったcampaignIdは親コンポーネントで別のキャンペーンが選択されると変わりうる(切り替え時に古いキャンペーンのデータが残ってはいけない)- モバイル回線(3G相当)からのアクセスを含み、APIレスポンスの到着順序がリクエスト発行順序と逆転することがある
- 本番ではエラートラッキング基盤と連携しており、fetch失敗時はUI上にエラー状態を表示する必要がある
StatsChartの再描画コスト(Canvas描画)は数百msかかるため、不要な再レンダーは体感速度に直結する
悪いコード (Before)
"use client";
import { useState, useEffect } from "react";
import { StatsChart } from "./StatsChart";
import { Alert } from "./Alert";
interface LiveStats {
orders: number;
revenue: number;
couponUsage: number;
}
export function CampaignLiveWidget({ campaignId }: { campaignId: string }) {
const [stats, setStats] = useState<LiveStats | null>(null);
const [history, setHistory] = useState<LiveStats[]>([]);
const [alertThreshold, setAlertThreshold] = useState(1000);
const [isOverThreshold, setIsOverThreshold] = useState(false);
// 問題①②③: 依存配列なし・cleanupなし・クロージャでhistoryを直接参照
useEffect(() => {
const timer = setInterval(() => {
fetch(`/api/campaigns/${campaignId}/live-stats`)
.then((res) => res.json())
.then((data: LiveStats) => {
setStats(data);
setHistory([...history, data]);
});
}, 3000);
});
// 問題④: 派生できる値をわざわざuseState+useEffectで複製
useEffect(() => {
setIsOverThreshold(!!stats && stats.orders > alertThreshold);
});
return (
<div className="campaign-live-widget">
<StatsChart
data={history}
onThresholdChange={(v: number) => setAlertThreshold(v)} // 問題⑤ inline関数
/>
{isOverThreshold && <Alert message="注文数が閾値を超えました" />}
</div>
);
}
// StatsChart.tsx(React.memoでラップ済みだが機能していない)
export const StatsChart = memo(function StatsChart({ data, onThresholdChange }) {
console.log("StatsChart rendered"); // 本来3秒に1回だけ呼ばれてほしい
return <canvas />; // 実際の描画は数百msかかる重い処理
// 問題⑥⑦(呼び出し元側): fetchにリクエスト順序保証なし、.catch()も無い
});
setHistory([...history, data])がクロージャ内の古いhistoryを参照isOverThresholdを別useState+useEffectで複製し1レンダー遅延onThresholdChangeが毎レンダー再生成.catch()が無く未処理rejection、UIへのフィードバックもないヒント(段階的開示)
ヒント1 — 方向性
setIntervalが積み上がり、誰もそれを片付けない。(2) 状態更新のクロージャ問題 — setIntervalのコールバック内でhistoryを直接参照しており、(1)の多重発火と組み合わさると更新が失われる(stale closure)。(3) 派生状態の二重管理 — isOverThresholdはstatsとalertThresholdから常に計算できる値なのに、わざわざuseState+useEffectで「1レンダー遅れた複製」を作っている。(4) 参照安定性とネットワーク競合 — インライン関数がReact.memoを無力化し、fetchにリクエスト順序の保証が無いため、遅れて届いた古いレスポンスが新しい状態を上書きしうる。
「このタブを5分開きっぱなしにしたブラウザが重くなったとき、原因をどう特定するか?」を自問すると(1)(2)の指摘が、「セール中にAPIへのリクエスト数が想定より多い」というログから逆算すると(1)の指摘がそれぞれ自然に出てくる。
ヒント2 — アプローチ
- 問題①②:
useEffectに[campaignId]を依存配列として渡し、cleanup関数(return () => {...})でclearIntervalと進行中のリクエストの中断を行う - 問題③:
setHistory([...history, data])ではなくsetHistory((prev) => [...prev, data])という関数型更新に変える - 問題④:
isOverThresholdを別stateとして持たず、レンダー中にstatsとalertThresholdから直接計算する(useMemoで「派生値である」ことを明示してもよい) - 問題⑤:
onThresholdChangeをuseCallbackでメモ化し、StatsChartに渡す関数の参照を安定させる - 問題⑥: リクエストごとに連番(もしくは
AbortController)を割り当て、「一番最後に発行したリクエストの応答だけを反映する」ガードを入れる - 問題⑦:
.catch()でfetchの失敗を捕捉し、エラー状態をUIに表示する
ヒント3 — コードの骨格
useEffect(() => {
setHistory([]); // キャンペーン切り替え時に前のデータを引きずらない
const controller = new AbortController();
const timer = setInterval(() => {
const requestId = ++latestRequestId.current;
fetch(`/api/campaigns/${campaignId}/live-stats`, { signal: controller.signal })
.then((res) => res.json())
.then((data: LiveStats) => {
if (requestId !== latestRequestId.current) return; // 古い応答は捨てる
setStats(data);
setHistory((prev) => [...prev, data]); // 関数型更新
})
.catch((err) => {
if (err.name !== "AbortError") setError(err.message);
});
}, POLL_INTERVAL_MS);
return () => {
clearInterval(timer);
controller.abort();
};
}, [campaignId]);
const isOverThreshold = useMemo(
() => stats !== null && stats.orders > alertThreshold,
[stats, alertThreshold]
);
const handleThresholdChange = useCallback((v: number) => setAlertThreshold(v), []);
問題点分析(7点)
| # | 問題点 | 分類 | 改善方法 |
|---|---|---|---|
| 1 | 依存配列なしでsetIntervalが毎レンダー積み上がる | Effectライフサイクル | useEffectに[campaignId]を明示 |
| 2 | cleanup無しでアンマウント後もタイマー継続 | メモリリーク | return文でclearInterval+AbortController.abort() |
| 3 | setHistory([...history,data])がstale closure | 状態更新 | 関数型更新setHistory(prev=>...) |
| 4 | isOverThresholdを別state+useEffectで複製 | 派生state | useMemoでstats/alertThresholdから直接計算 |
| 5 | onThresholdChangeが毎レンダー新規関数 | メモ化 | useCallbackで参照安定化 |
| 6 | fetchにリクエスト順序保証がなくrace condition | 非同期処理 | latestRequestIdで最新応答のみ反映 |
| 7 | .catch()が無く未処理rejection・エラー表示なし | エラー処理 | catch節+エラーstateでUI表示 |
構成図 — Bad vs Good(SVG)
模範解答
useEffect(() => {
const timer = setInterval(() => {
fetch(`/api/campaigns/${campaignId}/live-stats`)
.then((res) => res.json())
.then((data: LiveStats) => {
setStats(data);
setHistory([...history, data]);
});
}, 3000);
});
useEffect(() => {
setIsOverThreshold(!!stats && stats.orders > alertThreshold);
});
<StatsChart
data={history}
onThresholdChange={(v: number) => setAlertThreshold(v)}
/>
"use client";
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
import { StatsChart } from "./StatsChart";
import { Alert } from "./Alert";
/** ポーリング間隔(ms)。3秒ごとにキャンペーンのライブ指標を取得する。 */
const POLL_INTERVAL_MS = 3000;
interface LiveStats {
orders: number;
revenue: number;
couponUsage: number;
}
interface CampaignLiveWidgetProps {
/** 表示対象のキャンペーンID。切り替わったらポーリング先も切り替える。 */
campaignId: string;
}
/**
* キャンペーンのライブ指標(注文数/売上/クーポン使用数)を3秒間隔でポーリングし、
* 閾値超過時にアラートを表示するウィジェット。
*
* @param props.campaignId - 監視対象のキャンペーンID
*/
export function CampaignLiveWidget({ campaignId }: CampaignLiveWidgetProps) {
const [stats, setStats] = useState<LiveStats | null>(null);
const [history, setHistory] = useState<LiveStats[]>([]);
const [alertThreshold, setAlertThreshold] = useState(1000);
const [error, setError] = useState<string | null>(null);
// 同一マウント中に発行したポーリングリクエストへ連番を振り、
// 「一番最後に発行したリクエストの応答だけを反映する」ためのガード(問題⑥の解決)
const latestRequestId = useRef(0);
useEffect(() => {
// campaignIdが変わるたびにhistory/errorをリセットし、
// 前のキャンペーンのデータが新しいキャンペーンの表示に混ざらないようにする
setHistory([]);
setError(null);
const controller = new AbortController();
const timer = setInterval(() => {
const requestId = ++latestRequestId.current;
fetch(`/api/campaigns/${campaignId}/live-stats`, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`live-stats fetch failed: ${res.status}`);
return res.json() as Promise<LiveStats>;
})
.then((data) => {
// 自分より新しいリクエストが既に発行済みなら、遅れて届いた古い応答は捨てる(問題⑥の解決)
if (requestId !== latestRequestId.current) return;
setStats(data);
// クロージャ内のstateを直接参照せず、関数型更新で常に最新のstateへ積む(問題③の解決)
setHistory((prev) => [...prev, data]);
setError(null);
})
.catch((err: unknown) => {
if (err instanceof DOMException && err.name === "AbortError") return; // cleanup由来のabortは無視
setError(err instanceof Error ? err.message : "unknown error"); // 問題⑦の解決
});
}, POLL_INTERVAL_MS);
// アンマウント時・campaignId変更時に必ずタイマーとインフライトリクエストを止める(問題①②の解決)
return () => {
clearInterval(timer);
controller.abort();
};
}, [campaignId]); // 依存配列を明示し、campaignId変更時だけ再購読する(問題①の解決)
// isOverThresholdをstateとして複製せず、statsとalertThresholdから直接計算する(問題④の解決)
const isOverThreshold = useMemo(
() => stats !== null && stats.orders > alertThreshold,
[stats, alertThreshold]
);
// onThresholdChangeの参照を安定させ、StatsChart(React.memo)の不要な再レンダーを防ぐ(問題⑤の解決)
const handleThresholdChange = useCallback((value: number) => {
setAlertThreshold(value);
}, []);
return (
<div className="campaign-live-widget">
{error && <p role="alert">データ取得に失敗しました: {error}</p>}
<StatsChart data={history} onThresholdChange={handleThresholdChange} />
{isOverThreshold && <Alert message="注文数が閾値を超えました" />}
</div>
);
}
Before(Bad):
マウント直後: 1本目のsetIntervalが動き出す
親の再レンダー(別stateの変化やStrict Modeの二重実行など)が起きるたびに
依存配列の無いuseEffectが再実行され、そのたびに新しいsetIntervalが積み上がる
→ 5分間タブを開いた状態(≒仮に30回の親再レンダーが起きたケース)で
実質10本前後のインターバルが同時稼働
→ APIへのリクエスト数が本来の「3秒に1回」から「数秒おきに10回前後」へ増加
(セール中にバックエンドログで検知された過剰リクエストの原因)
→ アンマウント後もインターバルが動き続け、破棄済みコンポーネントへの
setStateで警告が発生し、メモリもリークする
history更新: setHistory([...history, data]) がクロージャ内の古いhistoryを参照するため、
複数intervalがほぼ同時に発火すると後着のsetHistoryが先着の更新を上書きし、
実際に届いたデータ件数よりhistoryの件数が少なくなる(グラフが飛び飛びになる)
isOverThresholdの更新: 別useEffectで1レンダー遅れて計算されるため、
「注文数が閾値を超えた瞬間」から実際にアラートが表示されるまでタイムラグがある
(「アラートが1テンポ遅れる」の原因)
onThresholdChangeが毎レンダーで新しい関数のため、StatsChart(React.memo)は
実質毎回再レンダーされ、Canvas描画コスト(数百ms)が不要なタイミングでも発生し続ける
After(Good):
マウント時に1本だけsetIntervalが起動し、親の再レンダーでは増えない
campaignIdが変わった時だけeffectが再実行され、旧タイマーはcleanupでclearIntervalされる
→ APIリクエストは常に3秒に1回のみ
history更新は関数型更新のため、同時に複数のfetchが解決してもデータが欠落しない
isOverThresholdはレンダー中に直接計算されるため、statsが更新された
同じレンダーでアラート表示に反映される(タイムラグが解消)
handleThresholdChangeの参照が固定されているため、StatsChartは
dataそのものが変わった時だけ再レンダーされる(Canvas描画コストが本来必要な頻度まで削減)
3G回線等で古いリクエストの応答が新しいリクエストより遅れて届いても、
latestRequestId.currentとの比較で無視され、画面が古いデータに巻き戻ることがない
| 問題 | 修正内容 | 効果 |
|---|---|---|
| ① 依存配列なしでtimer増殖 | useEffectに[campaignId]を明示 | campaignId変更時のみ再購読、timerは常に1本 |
| ② cleanupなしでリーク | returnでclearInterval+AbortController.abort() | アンマウント後の状態更新・リクエストを停止 |
| ③ stale closureで更新欠落 | setHistory(prev=>[...prev,data])の関数型更新 | 複数fetchが同時解決してもデータが欠落しない |
| ④ 派生stateの複製で遅延 | useMemoでstats/alertThresholdから直接計算 | statsの更新と同じレンダーでアラート反映 |
| ⑤ インライン関数でメモ化破壊 | useCallbackで参照安定化 | StatsChartがdata変化時のみ再レンダー |
| ⑥ race condition | latestRequestIdで最新応答のみ反映 | 古いレスポンスで画面が巻き戻らない |
| ⑦ エラー処理なし | .catch()+エラーstateでUI表示 | fetch失敗時にユーザーへフィードバック |
ポイント解説
setIntervalやイベントリスナーのコールバックのように「Effectの外側から非同期に呼ばれる関数」の中では、クロージャが古いレンダー時点のstateを参照し続ける典型的な罠(stale closure)になる。isOverThresholdはstatsとalertThresholdという既存のstateから100%計算できる値であり、それ自体をuseStateで持つ必要はない。単一の情報源から都度計算すれば、更新のタイミングずれ自体が原理的に発生しなくなる。useCallback/useMemoで参照を安定させて初めてReact.memoが意味を持つ。メモ化は「コンポーネントを包む」だけでなく「渡すpropsの参照安定性」まで含めて設計しないと効果が出ない。AbortController)を明示的に設計しないと、古いデータが新しいデータを上書きするrace conditionが発生する。実務への応用
MOpsチームの他のリアルタイム系ウィジェット(在庫アラート、クーポン残数モニターなど)にも今回と同じ「依存配列・cleanup・関数型更新・派生state・メモ化」のチェックリストがそのまま横展開できる。
08-16で扱ったArgo Workflowsのリトライ/冪等性設計や08-29で扱ったPub/Sub Push配信の順序保証で学んだ「非同期処理は発行順序と解決順序が一致しない」という原則が、フロントエンドのfetchポーリングにも全く同じ形で現れる好例。バックエンドで学んだ「最新のものだけを信頼する」設計思想はそのままReactにも輸入できる。
08-09で扱ったNext.js App Router弱点補強と合わせて、React/Next.jsのクライアントコンポーネント設計の基礎(サーバー/クライアント境界、Hooksのライフサイクル管理)が固まる。
今日のまとめ
次のステップ
- 発展問題: ポーリングをSSE(Server-Sent Events)やWebSocketに置き換えた場合、cleanup・再接続・バックオフ戦略をどう設計するか。
- 発展問題: React QueryやSWRなどのデータフェッチライブラリを導入した場合、今回手で書いた①〜⑦の対策のうちどれがライブラリ側の責務になり、どれが依然としてアプリ側の責務として残るかを整理する。
- 参考: React公式ドキュメント(You Might Not Need an Effect / Synchronizing with Effects / Removing Effect Dependencies)