弱点補強: Next.js App Router × React Server Components — ページ全体"use client"+useEffectでSSR放棄→async Server Component化 × fetch常時no-store→revalidate+tagsでキャッシュ戦略化 × location.reloadでフルリロード→Server Action+revalidatePath/revalidateTagで部分再検証 × 重量チャートライブラリ込みでページ全体"use client"→インタラクティブな葉だけを分離 × Server Action引数未検証→zod safeParseでサーバー側バリデーション × N+1ウォーターフォール→集約済みエンドポイント一本化 × 遅い集計API完了待ちで一覧まで巻き添え遅延→Suspenseでストリーミング(MOps キャンペーン管理画面 Next.js 15/React 19 App Router Bad→Good 7点)

2026-08-09 (Day 91) 日曜 弱点補強 ★★★★☆ Next.js 15 App Router / React 19 Server Components / Server Actions

概要

🌳

Server Componentがデフォルト、Client Componentは"必要な葉"だけ

App Routerでは"use client"を宣言しない限りコンポーネントはデフォルトでServer Component。今回のコードはページ全体に"use client"を付けてしまい、SSR・サーバー側データ取得・JSバンドル削減という恩恵をすべて自ら放棄していた。

🗂️

fetchキャッシュは"データの性質"で使い分ける

「常にno-store」は思考停止であり負荷とレイテンシのコストを常に払い続ける。多少の遅延を許容できるデータはnext.revalidateで緩くキャッシュし、書き込み直後の反映はrevalidatePath/revalidateTagによる明示的な再検証に任せる。

🌊

Suspenseは"遅い処理を隔離する"設計判断

集計API(2〜3秒)のように遅延が大きい処理をSuspense境界で囲むことで、遅い処理の完了を待たずに速い部分(一覧)から先にユーザーへ届けられる。バックエンドの「重い処理と軽い処理を分離する」原則と対称的。

🛡️

Server Actionでもサーバー側バリデーションは必須

フォームやボタンのonClickがクライアント側でどれだけ型安全に見えても、Server Actionの引数は外部からの入力として扱い、zod等でパースしてから初めて信頼する。CLAUDE.mdのガードレール「外部入力は必ずバリデーション」の実践そのもの。

問題

MOps チームでは、これまで Cloud Run 上のバックエンド API(Python)と BigQuery を中心に扱ってきたが、今回「キャンペーン管理画面」(/admin/campaigns)の刷新を Next.js(App Router, React 19 系)で担当することになった。実装したページは一応動いてはいるものの、社内フロントエンドレビューで7つの問題を指摘された。

対象ページの構成: /admin/campaigns(キャンペーン一覧+各キャンペーンのクーポン発行数+期間集計サマリー。集計はBigQueryバックエンドAPI経由で2〜3秒かかる)+ ステータス変更(有効化/停止)フォーム

制約・前提条件

  • Next.js 15系(App Router, React 19、Server Actions 安定版)
  • バックエンドAPI: GET /api/campaigns(一覧+クーポン発行数を1リクエストで返すエンドポイントが既に存在。個別N+1エンドポイントは廃止してよい)、GET /api/campaigns/summary(BigQuery集計、2〜3秒)、POST /api/campaigns/:id/status
  • バリデーションライブラリ: zod が利用可能
  • チャートライブラリ(heavy-chart-lib)はサマリー表示にのみ必要で、一覧表示には不要
  • 社内ガイドライン: 「ユーザー入力・外部APIレスポンスは必ずサーバー側でもバリデーションする」(クライアント側のみの検証で信頼しない)
  • ステータス変更後は一覧を再取得したいが、ページ全体のフルリロードは避けたい
期待する回答形式: 問題点の列挙(番号付き)+ 改善後コード(Server Component / Suspense境界 / Server Action / zodバリデーション)+ 実行例(操作→挙動の変化) + 設計意図の説明

悪いコード (Before)

このコードには 7つの構造的な問題 が隠れています。見つけてみてください。
現状 — ページ全体 "use client" + useEffect でのデータ取得
// app/admin/campaigns/page.tsx
"use client";

import { useEffect, useState } from "react";
import { LineChart } from "heavy-chart-lib"; // 問題④: 重いチャートライブラリ込みでページ全体use client

type Campaign = { id: string; name: string; status: "active" | "paused" };

export default function CampaignsPage() {
  const [campaigns, setCampaigns] = useState<Campaign[]>([]);
  const [couponCounts, setCouponCounts] = useState<Record<string, number>>({});
  const [summary, setSummary] = useState<{ totalRevenue: number } | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // 問題①: クライアント側useEffectでの取得 → SSRされず初回表示が真っ白になる
    fetch("/api/campaigns", { cache: "no-store" }) // 問題②: 常にno-store、キャッシュ戦略なし
      .then((res) => res.json())
      .then(async (data: Campaign[]) => {
        setCampaigns(data);
        // 問題⑥: キャンペーンごとに個別fetch(N+1ウォーターフォール)
        for (const c of data) {
          const res = await fetch(`/api/campaigns/${c.id}/coupon-count`);
          const { count } = await res.json();
          setCouponCounts((prev) => ({ ...prev, [c.id]: count }));
        }
        // 問題⑦: 一番遅い集計APIの完了まで一覧すら表示できない
        const summaryRes = await fetch("/api/campaigns/summary");
        setSummary(await summaryRes.json());
        setLoading(false);
      });
  }, []);

  async function handleToggleStatus(id: string, next: "active" | "paused") {
    await fetch(`/api/campaigns/${id}/status`, {
      method: "POST",
      body: JSON.stringify({ status: next }), // 問題⑤: サーバー側バリデーションなし
    });
    location.reload(); // 問題③: フルリロードでUXが悪化
  }

  if (loading) return <p>Loading...</p>;

  return (
    <div>
      <h1>キャンペーン一覧</h1>
      {summary && <LineChart data={summary} />}
      <ul>
        {campaigns.map((c) => (
          <li key={c.id}>
            {c.name} - クーポン発行数: {couponCounts[c.id] ?? "..."}
            <button onClick={() => handleToggleStatus(c.id, c.status === "active" ? "paused" : "active")}>
              {c.status === "active" ? "停止" : "有効化"}
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}
問題点サマリー(7点)
1ページ全体"use client"+useEffectでSSR放棄 — 初回表示が空のHTML+Loadingになり、LCPが悪化する
2fetch常時no-storeでキャッシュ戦略なし — 一覧のように多少の遅延を許容できるデータにも常に最新性を求め、不要な負荷とレイテンシを生む
3ステータス変更後にlocation.reloadでフルリロード — スクロール位置・他の状態がリセットされUXが悪化する
4重量チャートライブラリ込みでページ全体"use client" — 一覧を見るだけのユーザーにも不要なJSバンドルを配信
5Server側バリデーションなし — クライアントから届いたstatusをそのまま信頼して送信
6N+1ウォーターフォールfetch — キャンペーン数に比例してリクエスト数・待ち時間が増加
7遅い集計API完了待ちで一覧まで巻き添え遅延 — Suspenseによるストリーミングを使っていない

ヒント(段階的開示)

ヒント1 — 方向性
問題は3層に分類できる。(1) レンダリング戦略の誤り — App Router では "use client" を明示しない限りコンポーネントはデフォルトで Server Component であり、サーバー側でデータ取得とHTML生成まで済ませてからクライアントへ送るのが基本形。今回のコードは冒頭で "use client" を宣言してしまっているため、この恩恵(SSR・ゼロJSでの初期表示・サーバー側での安全なデータ取得)を自ら放棄している。(2) データ取得の並列性とキャッシュ戦略の欠如 — N+1のウォーターフォールfetchと、全APIをno-storeで毎回叩く設計は別の問題であり、「並列化すべきもの」と「キャッシュしてよいもの/都度最新にすべきもの」を区別できていない。(3) 信頼境界の欠如 — Server Action・API Routeであっても、クライアントから送られてくる値は外部入力であり、DB/BigQueryへの書き込み前にサーバー側で必ず検証が必要。

「このコンポーネントは本当にインタラクティブ(クリック・入力・状態を持つ)か?」を1つずつ問い、Yesの部分だけを小さく"use client"に切り出すのがApp Routerの基本設計思想であることを意識する。
ヒント2 — アプローチ
  • 問題①②: ページ全体"use client"+useEffectでのfetch → ページを async Server Component にし、fetch() をトップレベルで直接 await する(Server Component内のfetchはNext.jsのデータキャッシュ層と統合される)
  • 問題③: location.reload() によるフルリロード → Server Action でステータス変更を実行し、成功後に revalidatePath("/admin/campaigns")(またはrevalidateTag)でそのルートのキャッシュだけを再検証する
  • 問題④: 重いチャートライブラリを含むページ全体が"use client" → チャートを表示する部分だけを独立した async Server Component に切り出し、ボタン等の本当にインタラクティブな部分だけを小さな"use client"コンポーネントにする
  • 問題⑤: サーバー側バリデーションなし → Server Action内で zodsafeParse() を通してから初めてバックエンドAPIを呼ぶ
  • 問題⑥: キャンペーンごとの個別fetch(N+1) → バックエンドが返す集約済みエンドポイント/api/campaignsをそのまま使う(クライアント側でのループfetchを丸ごと削除する)
  • 問題⑦: 一番遅い集計APIの完了を全体で待つ → 一覧部分と集計サマリー部分を別々の async Server Component に分離し、サマリー部分だけを <Suspense fallback={...}> で包んでストリーミングする
ヒント3 — コードの骨格
// app/admin/campaigns/page.tsx (Server Component, asyncでOK)
import { Suspense } from "react";
import { CampaignSummary } from "./campaign-summary";
import { StatusToggleButton } from "./status-toggle-button"; // "use client"はここだけ

async function getCampaigns() {
  const res = await fetch(`${process.env.API_BASE_URL}/api/campaigns`, {
    next: { revalidate: 30, tags: ["campaigns"] }, // キャッシュ + タグ付け
  });
  return res.json();
}

export default async function CampaignsPage() {
  const campaigns = await getCampaigns(); // サーバー側で直接await

  return (
    <div>
      <h1>キャンペーン一覧</h1>
      <Suspense fallback={<p>集計データを読込中...</p>}>
        <CampaignSummary /> {/* 遅いAPIはここに隔離、一覧の表示をブロックしない */}
      </Suspense>
      <ul>
        {campaigns.map((c) => (
          <li key={c.id}>
            {c.name} - クーポン発行数: {c.couponCount}
            <StatusToggleButton campaignId={c.id} currentStatus={c.status} />
          </li>
        ))}
      </ul>
    </div>
  );
}

// app/admin/campaigns/actions.ts
"use server";
import { z } from "zod";
import { revalidatePath } from "next/cache";

const StatusSchema = z.object({
  campaignId: z.string().uuid(),
  status: z.enum(["active", "paused"]),
});

export async function updateCampaignStatus(input: unknown) {
  const parsed = StatusSchema.safeParse(input); // サーバー側で必ず検証
  if (!parsed.success) return { ok: false, error: "不正な入力です" };
  // バックエンドAPIへ反映...
  revalidatePath("/admin/campaigns"); // フルリロード不要
  return { ok: true };
}

問題点分析(7点)

#問題点分類改善方法
1ページ全体"use client"+useEffectでSSR放棄レンダリング戦略async Server Component化しトップレベルでawait
2fetch常時no-storeでキャッシュ戦略なしキャッシュ設計next.revalidate+tagsで性質に応じた再検証
3location.reloadによるフルリロード状態管理Server Action+revalidatePath/revalidateTag
4重量チャートライブラリ込みでページ全体"use client"バンドル最適化チャートをServer Componentに分離、use clientは葉のみ
5Server Action引数を未検証のまま送信信頼境界zod safeParseでサーバー側必須バリデーション
6キャンペーン毎の個別fetchでN+1ウォーターフォールデータ取得集約済みエンドポイント/api/campaigns一本化
7遅い集計API完了待ちで一覧まで巻き添え遅延ストリーミングSuspense境界で隔離しストリーミングSSR

構成図 — Bad vs Good(SVG)

Bad(変更前)— ページ全体 "use client" page.tsx("use client" + useEffect) 問題①: SSRされず初回表示が空のHTML+Loading 問題④: 重いチャートライブラリごとJSバンドルに含まれる fetch cache:no-store GET /api/campaigns(no-store固定) 問題②: 常に最新性を求め負荷とレイテンシを常時払う forループ内fetch GET /api/campaigns/:id/coupon-count × N件 問題⑥: N+1ウォーターフォール、件数比例で待ち時間増加 GET /api/campaigns/summary(2〜3秒) 問題⑦: この完了まで一覧すら表示できない(全体待ち) handleToggleStatus() → POST /status 問題⑤: クライアント値をそのまま送信、サーバー未検証 問題③: 成功後 location.reload() でページ全体を再構築 × SSRの恩恵をすべて自ら放棄している × キャッシュ戦略が「常に最新だが常に遅い」の一択 × 更新のたびにスクロール位置・状態がリセット × サーバー側の信頼境界チェックがない × 速い一覧まで遅いAPIの巻き添えで遅延 Good(変更後)— Server Component + Suspense + Server Action page.tsx(async Server Component, デフォルト) 修正①: トップレベルでawait、サーバー側でHTML生成 修正④: use clientはStatusToggleButtonのみに限定 revalidate:30, tags GET /api/campaigns(集約済み・1リクエスト) 修正⑥: クーポン数込みで返る、N+1個別fetchを廃止 Suspense境界 CampaignSummary(別Server Component, 2〜3秒) 修正⑦: Suspenseで隔離、一覧の表示をブロックしない heavy-chart-libもここに閉じ込め、一覧側JSは軽量 StatusToggleButton("use client", useTransition) クリック・pending状態だけがクライアント責務 それ以外はすべてServer Component updateCampaignStatus(Server Action, "use server") 修正⑤: zod safeParseでサーバー側必須バリデーション 修正③: revalidatePath/revalidateTagで部分再検証 ✓ サーバー側でHTML生成、初回表示が速い ✓ データの性質に応じたキャッシュ/再検証戦略 ✓ 更新はページ状態を保ったまま部分反映 ✓ サーバー側で外部入力を必ず検証 ✓ 遅いAPIが速い一覧の表示をブロックしない 修正

模範解答

Before — "use client" + useEffectでのfetch
"use client";
useEffect(() => {
  fetch("/api/campaigns", { cache: "no-store" })
    .then((res) => res.json())
    .then(async (data) => {
      // N+1個別fetch + 遅い集計APIの完了待ち...
    });
}, []);
After — async Server Component + Suspense
// app/admin/campaigns/page.tsx
// デフォルトでServer Component。"use client"を宣言しない。
import { Suspense } from "react";
import { CampaignSummary } from "./campaign-summary";
import { StatusToggleButton } from "./status-toggle-button";

type Campaign = {
  id: string;
  name: string;
  status: "active" | "paused";
  couponCount: number; // バックエンド側で集約済み(N+1解消, 修正⑥)
};

/**
 * キャンペーン一覧を取得する。
 *
 * Server Component内でのfetchはNext.jsのデータキャッシュ層と統合され、
 * next.revalidate 秒間はキャッシュされたレスポンスが再利用される。
 *
 * Returns:
 *   Campaign[]: クーポン発行数を含むキャンペーン一覧
 */
async function getCampaigns(): Promise<Campaign[]> {
  const res = await fetch(`${process.env.API_BASE_URL}/api/campaigns`, {
    next: {
      revalidate: 30, // 修正②: 30秒間キャッシュ再利用(一覧は多少の遅延を許容)
      tags: ["campaigns"], // Server Action側からrevalidateTagで無効化可能
    },
  });
  if (!res.ok) throw new Error(`campaigns fetch failed: ${res.status}`);
  return res.json();
}

export default async function CampaignsPage() {
  const campaigns = await getCampaigns(); // 修正①: サーバー側でawait

  return (
    <div>
      <h1>キャンペーン一覧</h1>

      {/* 修正⑦: 遅い集計APIはSuspense境界の内側に隔離 */}
      <Suspense fallback={<p>集計データを読込中...</p>}>
        <CampaignSummary />
      </Suspense>

      <ul>
        {campaigns.map((c) => (
          <li key={c.id}>
            {c.name} - クーポン発行数: {c.couponCount}
            {/* 修正④: インタラクティブな部分だけをclient componentに分離 */}
            <StatusToggleButton campaignId={c.id} currentStatus={c.status} />
          </li>
        ))}
      </ul>
    </div>
  );
}
Before — チャートがページ全体のuse clientに同居
"use client";
import { LineChart } from "heavy-chart-lib";
// ページ全体のバンドルにチャートライブラリが含まれる
// 一覧を見るだけのユーザーにも不要なJSが配信される
After — チャートを独立したServer Componentに分離
// app/admin/campaigns/campaign-summary.tsx
// これもServer Component。重いチャートライブラリはここにだけ閉じ込める。
import { LineChart } from "heavy-chart-lib";

/**
 * BigQuery集計API(レイテンシ大)を呼び出し、サマリーチャートを描画する。
 *
 * page.tsx側でSuspenseに包まれているため、このコンポーネントの解決を
 * 一覧表示は待たない(ストリーミングSSR)。
 */
export async function CampaignSummary() {
  const res = await fetch(`${process.env.API_BASE_URL}/api/campaigns/summary`, {
    next: { revalidate: 60, tags: ["campaigns-summary"] },
  });
  const summary: { totalRevenue: number; series: number[] } = await res.json();
  return <LineChart data={summary} />;
}
Before — location.reloadでフルリロード
async function handleToggleStatus(id, next) {
  await fetch(`/api/campaigns/${id}/status`, {
    method: "POST",
    body: JSON.stringify({ status: next }),
  });
  location.reload(); // ページ全体を再構築
}
After — Server Action + useTransition
// app/admin/campaigns/status-toggle-button.tsx
"use client"; // ボタンのクリック・pending状態管理だけがクライアント責務

import { useTransition } from "react";
import { updateCampaignStatus } from "./actions";

const NEXT_STATUS = { active: "paused", paused: "active" } as const;

export function StatusToggleButton({
  campaignId,
  currentStatus,
}: {
  campaignId: string;
  currentStatus: "active" | "paused";
}) {
  const [isPending, startTransition] = useTransition();

  function handleClick() {
    startTransition(async () => {
      const result = await updateCampaignStatus({
        campaignId,
        status: NEXT_STATUS[currentStatus],
      });
      if (!result.ok) {
        console.error(result.error); // 本来はtoast通知等で提示
      }
      // location.reload()は不要。Server Action内のrevalidatePathが
      // page.tsxのキャッシュを無効化し、Reactが差分更新する。
    });
  }

  return (
    <button onClick={handleClick} disabled={isPending}>
      {isPending ? "更新中..." : currentStatus === "active" ? "停止" : "有効化"}
    </button>
  );
}
// app/admin/campaigns/actions.ts
"use server";

import { z } from "zod";
import { revalidatePath, revalidateTag } from "next/cache";

/** Server Actionへの入力スキーマ。クライアントの型を一切信頼しない。 */
const UpdateStatusSchema = z.object({
  campaignId: z.string().uuid(),
  status: z.enum(["active", "paused"]),
});

type UpdateStatusResult = { ok: true } | { ok: false; error: string };

/**
 * キャンペーンのステータスを更新する Server Action。
 *
 * Args:
 *   input: クライアントから渡される未検証の値(unknown扱いする)
 *
 * Returns:
 *   UpdateStatusResult: 成功/失敗と失敗理由
 */
export async function updateCampaignStatus(input: unknown): Promise<UpdateStatusResult> {
  const parsed = UpdateStatusSchema.safeParse(input); // 修正⑤: サーバー側必須バリデーション
  if (!parsed.success) {
    return { ok: false, error: "不正な入力です" };
  }

  const res = await fetch(
    `${process.env.API_BASE_URL}/api/campaigns/${parsed.data.campaignId}/status`,
    {
      method: "POST",
      body: JSON.stringify({ status: parsed.data.status }),
      headers: { "Content-Type": "application/json" },
    },
  );
  if (!res.ok) {
    return { ok: false, error: "更新に失敗しました" };
  }

  revalidatePath("/admin/campaigns"); // 修正③: 一覧ページのキャッシュだけを無効化
  revalidateTag("campaigns"); // 同タグを参照する他ルートも連動して無効化
  return { ok: true };
}
操作: 「有効化」ボタンをクリック

Before: ページ全体がリロードされ、白画面 → 再構築
        (スクロール位置リセット、集計チャートも再取得)

After : ボタンが「更新中...」に変わる(useTransitionのpending状態) →
        Server Actionが成功 → revalidatePathでpage.tsxのキャッシュのみ無効化 →
        一覧部分だけが差分更新され、集計チャートやスクロール位置は保持される
問題修正内容効果
① SSR放棄async Server Component化しトップレベルでawaitサーバー側でHTML生成、初回表示が速くなる
② no-store固定next.revalidate=30+tagsでキャッシュ戦略化不要な負荷とレイテンシを削減
③ location.reloadServer Action+revalidatePath/revalidateTagページ状態を保ったまま部分更新
④ ページ全体use clientCampaignSummaryを分離、use clientは葉のみ不要なJSバンドル配信を削減
⑤ サーバー未検証zod safeParseでサーバー側必須バリデーション外部入力を信頼境界で検証
⑥ N+1ウォーターフォール集約済み/api/campaigns一本化リクエスト数・待ち時間を削減
⑦ 遅いAPIで一覧まで遅延Suspense境界で隔離しストリーミング速い一覧を先に表示できる

ポイント解説

1Server Componentがデフォルト、Client Componentは"必要な葉"だけに絞る — App Routerでは「まずServer Componentで書き、インタラクティブ性が必要な最小単位だけを"use client"に切り出す」のが基本設計。今回は「ボタンのクリックとpending状態」だけがクライアント責務であり、データ取得・レンダリングはすべてサーバー側で完結できた。
2fetchキャッシュは"データの性質"で使い分ける — 一覧(多少の遅延を許容できる)はrevalidate: 30で緩くキャッシュし、書き込み直後の反映はrevalidatePath/revalidateTagによる明示的な再検証に任せる。「常にno-store」は最も安全に見えて実は思考停止であり、負荷とレイテンシのコストを常に払い続けている。
3Suspenseによるストリーミングは"遅い処理を隔離する"設計判断 — 集計APIのように遅延が大きい処理をSuspense境界で囲むことで、遅い処理の完了を待たずに速い部分(一覧)から先にユーザーへ届けられる。これはAPI設計における「タイムアウトを分離する」考え方とも通じる。
4Server Actionでもサーバー側バリデーションは必須 — フォームやボタンのonClickがクライアント側でどれだけ型安全に見えても、Server Actionの引数は外部からの入力として扱い、zod等でパースしてから初めて信頼する。これはAPI Routeやバックエンド全般に共通するガードレールの原則と同じ。

実務への応用

社内Admin UI(Cloud Run上でホスト、07-26/08-02の弱点補強で扱ったIAP対象システム)を今後Next.jsで刷新する際、この「Server Component中心+Client Componentは葉のみ」の設計方針をチーム標準として展開できる。バックエンドAPI(Python)はデータ取得・書き込みに専念し、Next.js側はサーバーサイドレンダリングとキャッシュ制御に専念する役割分担が明確になる。

revalidateTagによるキャッシュ無効化は、dbtのincremental更新やArgo Workflowsのバッチ完了通知と組み合わせて「バッチ集計が更新されたらダッシュボードのキャッシュも自動で無効化する」設計に拡張できる(BigQueryバッチ完了→Pub/Sub→Next.js側のrevalidateTag呼び出しAPIをキック、という構成)。

Suspenseによる部分ストリーミングの考え方は、07-25/08-01/08-08で扱ってきたバックエンド側の「重い処理と軽い処理を分離する」設計判断と対称的。フロントもバックも「遅い処理をユーザーの待ち時間から隔離する」という同じ原則で設計できることを意識する。

Server Actionのzodバリデーションは、CLAUDE.mdのガードレール「ユーザー入力・外部APIレスポンスは必ずバリデーションする」の実践そのもの。 フロントエンドのフォームバリデーションはUX向上のためのものであり、信頼境界の防御はサーバー側(Server Action/API Route)に必ず置く、という区別をチームに周知できる。

今日のまとめ

App RouterにおけるNext.jsの弱点は、「Server Componentがデフォルトである」という前提を意識せず"use client"をページ全体に付けてしまうことから連鎖的に発生していた。データ取得をサーバー側に戻し、キャッシュ戦略(revalidate/revalidateTag)・部分ストリーミング(Suspense)・信頼境界のバリデーション(zod+Server Action)という3つの軸で設計し直すことで、UXとセキュリティの両方を改善できることを確認した。

次のステップ

  • 発展問題: updateCampaignStatus の呼び出しが同時に複数箇所(一覧のボタン、詳細ページの別ボタン)から発生する場合の楽観的UI更新(useOptimistic)の設計を検討する。
  • 発展問題: CampaignSummary のBigQuery集計APIがタイムアウトした場合のエラーバウンダリ(error.tsx)設計と、部分的な失敗時のフォールバック表示を追加する。
  • 参考: Next.js公式ドキュメント(Server Components / Server Actions / fetchのキャッシュとrevalidate / Suspenseとストリーミング)、React公式ドキュメント(useTransition, useOptimistic

自己評価(あとで記入)

自分の回答

気づき・メモ