弱点補強: React Hooks アンチパターン — useEffect依存配列無し+cleanup無しでsetIntervalが積み上がりアンマウント後もリクエスト継続→[campaignId]依存配列+clearInterval/AbortControllerで解決 × setHistory([...history,data])のクロージャがstale closureで更新消失→関数型setState(prev => ...)で解決 × isOverThresholdをuseState+useEffectで複製し1レンダー遅延→statsとalertThresholdからuseMemoで直接計算 × onThresholdChangeが毎レンダー新規インライン関数でReact.memoが機能不全→useCallbackで参照安定化 × fetchにリクエスト順序保証がなくrace condition→latestRequestIdによる最新応答ガード × エラーハンドリング無しで未処理rejection→エラー状態表示を追加(MOps 販促システムチーム CampaignLiveWidget Next.js 15 App Router Bad→Good 7点)

2026-08-30 (Day 146) 日曜 弱点補強 ★★★★☆ React / Next.js 15 App Router Hooks / stale closure / race condition

概要

⏱️

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でラップして不要な再描画を防ぐ設計になっているはずだった。

先週のセールで以下3つの苦情が同時に上がり、フロントエンドに詳しいエンジニアがレビューしたところ7つの問題が見つかった。
  • 「ダッシュボードのタブを開きっぱなしにしていると、時間が経つほどブラウザが重くなる」
  • 「注文数が閾値を超えてからアラートが出るまで、体感で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かかるため、不要な再レンダーは体感速度に直結する
期待する回答形式: 問題点の列挙(番号付き)+ 改善後コード(TSX、useEffect依存配列/cleanup関数/関数型state更新/useMemo/useCallback/リクエスト順序ガード)+ 実行例(Before/Afterの挙動比較)+ 設計意図の説明

悪いコード (Before)

このコードには 7つの構造的な問題 が隠れています。見つけてみてください。
現状 — CampaignLiveWidget.tsx
"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()も無い
});
問題点サマリー(7点)
1依存配列なしでsetIntervalが積み上がる — レンダーの度に新しいタイマーが生成され、誰も片付けない
2cleanup関数がない — アンマウント後もタイマーが動き続けメモリリーク
3stale closure — setHistory([...history, data])がクロージャ内の古いhistoryを参照
4派生stateの二重管理 — isOverThresholdを別useState+useEffectで複製し1レンダー遅延
5React.memoの機能不全 — インライン関数onThresholdChangeが毎レンダー再生成
6race condition — fetchにリクエスト順序保証がなく古い応答が新しい状態を上書き
7エラーハンドリングなし — .catch()が無く未処理rejection、UIへのフィードバックもない

ヒント(段階的開示)

ヒント1 — 方向性
問題は4層に分類できる。(1) Effectのライフサイクル管理の誤り — 依存配列が無くcleanup関数も無いため、レンダーの度に新しい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]を明示
2cleanup無しでアンマウント後もタイマー継続メモリリークreturn文でclearInterval+AbortController.abort()
3setHistory([...history,data])がstale closure状態更新関数型更新setHistory(prev=>...)
4isOverThresholdを別state+useEffectで複製派生stateuseMemoでstats/alertThresholdから直接計算
5onThresholdChangeが毎レンダー新規関数メモ化useCallbackで参照安定化
6fetchにリクエスト順序保証がなくrace condition非同期処理latestRequestIdで最新応答のみ反映
7.catch()が無く未処理rejection・エラー表示なしエラー処理catch節+エラーstateでUI表示

構成図 — Bad vs Good(SVG)

Bad(変更前)— 依存配列なし・cleanupなし useEffect(() => { setInterval(...) }) 問題①: 依存配列なし → 親の再レンダー毎に新しいtimerが生成 問題②: cleanupなし → アンマウント後もtimerが動き続ける 問題③: setHistory([...history,data]) がクロージャの古いhistoryを参照 timerが複数同時稼働 → fetchが3秒に1回のはずが数秒おきに多数発火 → APIリクエスト数の異常増加(セール中に検知) useEffect(() => { setIsOverThreshold(...) }) 問題④: statsから直接計算せず別stateに複製 → statsが更新された次のレンダーでようやく反映 → アラート表示が1テンポ遅れる <StatsChart onThresholdChange={inline fn} /> 問題⑤: 毎レンダーで新しい関数 → React.memoが無効化 問題⑥⑦: fetchに順序保証・エラー処理なし → 古い応答で上書き/エラー時に画面が沈黙 × タブを開き続けるほどタイマー/リクエストが増殖 × historyの更新が欠落しグラフが飛び飛びになる × アラート表示に1レンダー分のタイムラグ × 重いCanvas再描画が不要なタイミングでも発生 × 3G回線で古いデータが新しいデータを上書き Good(変更後)— 依存配列+cleanup+ガード useEffect(..., [campaignId]) 修正①②: campaignId変更時のみ再購読、returnでclearInterval+abort() 修正③: setHistory(prev => [...prev, data]) で更新欠落を防止 修正⑥: requestId !== latestRequestId.current なら古い応答を破棄 修正⑦: .catch()でエラーを捕捉しsetError → timerは常に1本、fetchは常に3秒に1回のみ const isOverThreshold = useMemo(...) 修正④: stats/alertThresholdから同じレンダーで直接計算 → statsの更新と同じレンダーでアラートが反映(遅延解消) <StatsChart onThresholdChange={handleThresholdChange} /> 修正⑤: useCallbackで参照を固定 → React.memoが正しく機能 → dataが変わった時だけCanvas再描画 ✓ APIリクエストは常に3秒に1回のみ ✓ historyの更新欠落なし、グラフが連続表示 ✓ アラートがstats更新と同じレンダーで反映 ✓ 不要なCanvas再描画が発生しない ✓ 古いレスポンスで画面が巻き戻らない 修正

模範解答

Before — 依存配列なし・cleanupなし
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)}
/>
After — 依存配列+cleanup+関数型更新+ガード
"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 conditionlatestRequestIdで最新応答のみ反映古いレスポンスで画面が巻き戻らない
⑦ エラー処理なし.catch()+エラーstateでUI表示fetch失敗時にユーザーへフィードバック

ポイント解説

1useEffectの依存配列は「いつ再実行すべきか」の宣言であり、省略は「毎レンダー実行する」という強い意思表示になる — 依存配列を省略した実装は一見動いてしまうためバグに気づきにくい。cleanup関数とセットで「このEffectが管理するリソース(タイマー・購読・インフライトリクエスト)のライフサイクルを、コンポーネント自身のライフサイクルと一致させる」ことがuseEffectの本質であり、依存配列とcleanupはその宣言の両輪である。
2setState内でstateを直接参照するクロージャは「古い値を掴んだまま」になりうる。関数型更新は常に最新のstateを保証する — 特にsetIntervalやイベントリスナーのコールバックのように「Effectの外側から非同期に呼ばれる関数」の中では、クロージャが古いレンダー時点のstateを参照し続ける典型的な罠(stale closure)になる。
3派生できる値をstateとして複製すると、「複数の真実の情報源」が生まれ同期ずれのタイムラグが発生する — isOverThresholdはstatsとalertThresholdという既存のstateから100%計算できる値であり、それ自体をuseStateで持つ必要はない。単一の情報源から都度計算すれば、更新のタイミングずれ自体が原理的に発生しなくなる。
4React.memoは「propsの参照が変わらない限り再レンダーしない」仕組みであり、インライン関数・インラインオブジェクトはメモ化を機能不全にする — useCallback/useMemoで参照を安定させて初めてReact.memoが意味を持つ。メモ化は「コンポーネントを包む」だけでなく「渡すpropsの参照安定性」まで含めて設計しないと効果が出ない。
5非同期処理の「発行順序」と「解決順序」は一致しない — ネットワーク遅延によりレスポンスの到着順が逆転しうるため、「一番最後に発行したリクエストの応答だけを信頼する」ガード(リクエストID比較や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のライフサイクル管理)が固まる。

開発環境のReact Strict Modeではeffectが意図的に2回実行される。今回のようにcleanup関数を正しく実装していれば実害は無いが、「cleanup無しでもStrict Modeで動いて見えた」ことに気づかず本番でリークするケースへの注意喚起にもなる。

今日のまとめ

React Hooksの弱点は「動く画面を作ること」と「Effectが管理するリソースのライフサイクルをコンポーネントのライフサイクルと正確に一致させること」の違いを意識していない点にあった。依存配列とcleanupによるタイマー/リクエストの後始末、関数型state更新によるstale closure回避、派生値の一元管理、useCallback/useMemoによる参照安定化、非同期レスポンスの順序保証——これら5つはいずれも「このコンポーネントが5分間開かれ続けたとき、リソースは増え続けずに済むか」という1つの問いから導かれる設計原則である。

次のステップ

  • 発展問題: ポーリングをSSE(Server-Sent Events)やWebSocketに置き換えた場合、cleanup・再接続・バックオフ戦略をどう設計するか。
  • 発展問題: React QueryやSWRなどのデータフェッチライブラリを導入した場合、今回手で書いた①〜⑦の対策のうちどれがライブラリ側の責務になり、どれが依然としてアプリ側の責務として残るかを整理する。
  • 参考: React公式ドキュメント(You Might Not Need an Effect / Synchronizing with Effects / Removing Effect Dependencies)

自己評価(あとで記入)

自分の回答

気づき・メモ