Next.js App Router での「更新後のUI最新化」をパターンで整理する
この記事はファインディエンジニア Advent Calendar 2025 22日目の記事です。
1. はじめに
Next.js App Router では、Server Component(SC)でデータを取得しレンダリングするのが自然な形です。ページを開けばサーバー側でデータを取得し、レンダリング済みのコンテンツがユーザーに届く。ここまではシンプルです。
しかし、ユーザー操作でデータが変わったあと 「UI をどう最新化するか」 となると、急に選択肢が増えます。router.refresh() で SC を再評価すべき? Server Actions 内で revalidatePath を呼ぶべき? それともクライアント側でデータを管理する?
これらは一見バラバラですが、突き詰めると 「データの拠り所をサーバーに置くか、クライアントに置くか」 という設計判断に収束します。
| 方向性 | 手段の例 | データの拠り所 |
|---|---|---|
| サーバー主導 |
refresh, revalidatePath
|
SC(サーバー) |
| クライアント主導 |
useState, React Query / SWR |
クライアント状態 |
本記事ではこの視点から各パターンを整理し状況に応じた選択の指針を示します。どちらが正解というわけではなく、どのような判断軸を持てるのかについて考えていければと思います。
本記事の前提
- 初回データ取得は SC で行う(App Router のデフォルト)
- データ表示と更新 UI が同一ページ内に存在する(タスク一覧 + 追加フォーム、いいねボタンなど)
- 図解ではデータストアにアクセスする処理は API Server として分離しているが、RSC / Server Actions で直接アクセスする構成でも本質は同じ
2. 更新が「素の fetch / Form submit」の場合
まずは Server Actions を使わず、標準のフォーム送信や Fetch API といったプリミティブな手段で更新するケースから見ていきます。ここで出てくる「サーバー主導」と「クライアント主導」という2つの方向性は、3. の Server Actions でも同じ構図で現れます。基本形を理解しておくと後のセクションもスムーズに読み進められるかと思います。
2.1 Post/Redirect/Get (PRG) パターン
フォーム送信後に画面を最新化する方法として、まず最も古典的な PRG(Post/Redirect/Get) パターンから見ていきましょう。
Note: React や Next.js での開発では PRG パターンを扱う機会は少ないと思いますが、後続のパターンと比較する際のベースラインとして明示するために、冒頭で取り上げたいと思います。
PRG パターンは Web の黎明期から存在するパターンで、名前の通り 3 つのステップで構成されています。
- Post: ブラウザがフォームデータを POST で送信
- Redirect: サーバーが処理完了後、303 リダイレクトを返す
- Get: ブラウザがリダイレクト先に GET リクエストを送り、結果ページを表示
JavaScript 不要でブラウザのネイティブな挙動だけで完結する一方、リダイレクトによる ハードナビゲーション(ページ全体の再読み込み) が発生します。PRG の核心は「POST の結果を GET で見に行け」という HTTP の意味論にあります。
POST → 303 → GET というラウンドトリップが発生し、303 リダイレクトはハードナビゲーションを伴うため、クライアント側の状態はすべてリセットされます。
実装イメージ
// app/tasks/page.tsx - JavaScript 不要の純粋な HTML フォーム
export default async function TasksPage() {
const tasks = await getTasks(); // リダイレクト後に再実行
return (
<main>
<form method="POST" action="/api/tasks">
<input type="text" name="title" required />
<button type="submit">追加</button>
</form>
<TaskListClient tasks={tasks} />
</main>
);
}
API サーバーは 303 See Other と Location: /tasks を返し、ブラウザが自動的にリダイレクト先へハードナビゲーションします。
PRG の課題
- 通信コスト: POST → 303 → GET の 3 段階ラウンドトリップに加え、ハードナビゲーションにより HTML・CSS・JS などの静的アセットも再取得される
- UX の制約: スクロール位置・フォーカス・クライアント状態がリセットされ、画面がちらつく。楽観的 UI や進捗表示も困難
PRG を選ぶべきシチュエーション
上記の課題にもかかわらず、PRG が最適解となるケースは存在します。
- プログレッシブエンハンスメント: JavaScript 無効環境でもフォーム送信が確実に動作する必要がある場合。PRG はブラウザのネイティブ動作だけで完結するため、その基盤となる
- 既存 MPA からの移行: 従来の MPA アーキテクチャからの移行期に、既存の PRG フローとの互換性を維持したい場合。ナビゲーション挙動をブラウザ標準に揃えることで移行リスクを軽減できる
- 高信頼性が求められる画面: 更新直後の HTML がサーバーで確定されてから表示されることが重要な画面。楽観的 UI よりも PRG の「確実性」が優先される
PRG は堅牢性ゆえに有効なシチュエーションが存在しますが、SPA 的な UX が求められる場面では課題があります。次のセクションでは、PRG の課題を解決しつつサーバー主導の整合性を維持するパターンを見ていきます。
2.2 fetch + SC再評価パターン
PRG パターンの「ハードナビゲーションによる UX の劣化」を解決しつつ、サーバー主導の整合性を維持するのがこのパターンです。
このパターンの特徴
従来の SPA でのサーバー通信同様に、Client Component から fetch で API を呼び出し、成功後に router.refresh() で Server Component を再評価します。PRG との最大の違いは、クライアント状態が保持されることです。
| 項目 | PRG (2.1) | fetch + refresh (2.2) |
|---|---|---|
| 更新手段 | 純粋 HTML フォーム | Client Component + fetch |
| レスポンス | 303 リダイレクト | JSON |
| UI 更新 | ハードナビゲーション | ソフトナビゲーション |
| クライアント状態 | リセットされる | 保持される |
router.refresh() は Next.js の useRouter フックが提供するメソッドで、以下の動作を行います。
- Router Cache をクリア: クライアント側にキャッシュされた RSC Payload を破棄
- SC を再評価: サーバーに新しい RSC Payload をリクエスト
- 差分更新: 新しい RSC Payload をクライアントの React ツリーにマージ
重要なのは、これがハードナビゲーション(フルページリロード)ではないことです。ブラウザの履歴は変わらず、クライアント状態は保持されます。
fetch + refresh パターンでは、以下のようなデータフローになります。
実装イメージ
このパターンでは、ページを構成するコンポーネントの役割が明確に分かれます。
- Server Component (page.tsx): データ取得と初期表示
-
Client Component: ユーザー操作のハンドリングと
router.refresh()の呼び出し - API サーバー: データの更新処理と JSON レスポンス
ページコンポーネント(Server Component)
// app/tasks/page.tsx
import { TaskFormClient } from "./_components/TaskFormClient";
import { TaskList } from "./_components/TaskList";
export default async function TasksPage() {
// Server Component なのでサーバー側でデータ取得
const tasks = await getTasks();
return (
<main>
<TaskFormClient />
<TaskList tasks={tasks} />
</main>
);
}
フォームコンポーネント(Client Component)
"use client";
import { useRouter } from "next/navigation";
export function TaskFormClient() {
const router = useRouter();
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const res = await fetch("/api/tasks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: formData.get("title") }),
});
if (res.ok) {
e.currentTarget.reset();
router.refresh(); // ← SC を再評価して最新データを取得
}
};
return (
<form onSubmit={handleSubmit}>
<input name="title" />
<button>追加</button>
</form>
);
}
サーバー側の処理
サーバー側は一般的な JSON API として実装します。Next.js の Route Handler でも外部 API(REST, GraphQL)でも、POST リクエストを受けてデータを永続化し結果を JSON で返すだけです。PRG のようなリダイレクトは返しません。UI の更新はクライアント側の router.refresh() が担います。
このパターンのポイント: SPA 的な UX とサーバー主導の整合性の両立
このパターンの本質は、SPA 的な UX(ハードナビゲーションなし、スムーズな更新体験)と、サーバー主導の整合性(router.refresh() による SC 再評価)を両立できる点にあります。
スクロール位置やフォーカス状態、入力中のフォームの値、モーダルの開閉状態などクライアント状態がリセットされず、かつデータの拠り所はサーバー側に保たれます。JSON レスポンスを受け取るため、バリデーションエラーの表示やリトライロジックなど、エラー時の処理も柔軟に制御できます。
このパターンを選ぶべきシチュエーション
- 既存の SPA に近い設計を維持したい場合(従来のクライアント処理を活かしつつサーバー主導の整合性を取り入れたい)
- Server Actions を利用できない環境(何らかの理由で Server Actions が使えない場合の選択肢)
- ソフトナビゲーションならではの UX が必要な場合(クライアント状態の保持、画面のちらつき回避、柔軟なエラーハンドリング)
- ページ規模が小さく再レンダリングコストが問題にならない場合
このパターンの課題
refresh のコストはページ規模に比例: router.refresh() は現在のルート全体の SC を再評価するため、小さな更新(1件のタスクのトグルなど)でもページ全体のデータを再取得することになります。この問題は Data Cache や Cache Components であらかじめキャッシュしておき、revalidateTag 等で対象データのみ更新することで軽減可能です。
非同期処理の複雑さ: fetch の結果を待ってから router.refresh() を呼ぶ必要があるため、ローディング状態の管理やエラーハンドリングのコードが増えます。useState で isLoading を管理し、try...finally で確実に状態を解除するなど、適切なパターンが求められます。
2度のラウンドトリップ: データ更新用の fetch と SC 再評価用の router.refresh() で、2度のブラウザ/サーバー間通信が発生します。この課題は Server Actions で改善できます(3. で詳述)。
まとめ
fetch + refresh パターンは、PRG(2.1)と Server Actions(3.)の中間に位置する方式です。従来の SPA 開発で馴染みのある fetch を使いつつ、router.refresh() でサーバー主導の整合性を得られるため、App Router への移行期や Server Actions を導入する前段階として有効な選択肢となります。
次のセクションでは、サーバー主導ではなくクライアント側でデータを管理するパターンを見ていきます。
2.3 クライアント管理パターン
ここまで見てきた PRG と fetch + refresh は、どちらも サーバー主導 のパターンでした。更新後に SC を再評価することで、データの拠り所をサーバー側に保っています。
このセクションでは、その方針を転換し、クライアント側でデータを管理する パターンを見ていきます。
このパターンの特徴
このパターンでは、SC で取得したデータはクライアント側の初期データとして扱い、以降の更新処理と UI 描画はクライアント側で完結させます。
つまり、router.refresh() よる SC の再評価は発生させず、クライアントのデータを直接更新する という発想です。クライアント側の状態管理には従来通り、 useState や任意のデータフェッチライブラリ(TanStack Query や SWR )が利用できます。2.2 との違いを表にまとめると以下のようになります。
| 項目 | fetch + refresh (2.2) | クライアント状態 (2.3) |
|---|---|---|
| 更新後の流れ | fetch → router.refresh() → SC 再評価 |
fetch → クライアント状態を更新 |
| データの真実 | Server Component | クライアント側の状態/キャッシュ |
| SC 再評価 | 毎回発生 | 発生しない |
このパターンのデータフローは、2.2 と比較すると 後半が大きく異なります。
従来の開発パターンとの関係
実はこのパターン、多くの開発者にとって 最も馴染み深い方法 です。
従来の SPA や、Pages Router では
-
useEffectでデータを取得しuseStateで管理したり、データキャッシュライブラリでデータ取得とクライアントキャッシュを管理 -
getServerSidePropsによりサーバー側でデータを取得し、それを props として渡してクライアントに格納する
などの形が一般的でした。
今回のパターンでは、これらのデータ取得部分が Server Component に置き換わったのみで、クライアント側でのデータ管理・更新ロジックはそのまま使えます。
一方でこのパターンを選ぶということは、App Router の機構(router.refresh() やキャッシュ)を使わず、従来通りの設計を維持するという選択でもあります。これは決して悪いことではありませんが、RSC の利点を十分に活かしているとは言えない点は理解しておく必要があります。
実装イメージ
このパターンでは、フォームとリストを 1つの Client Component に統合 するのが自然です。状態を共有する必要があるためです。
ページコンポーネント(Server Component)
// app/tasks/page.tsx
import { TaskManagerClient } from "./_components/TaskManagerClient";
export default async function TasksPage() {
// 初回のみ SC でデータ取得(SSR の恩恵)
const tasks = await getTasks();
return (
<main>
{/* 初期データを props として渡す */}
<TaskManagerClient initialTasks={tasks} />
</main>
);
}
SC の役割は 初回データの取得と SSR のみです。以降の操作はすべて CC 内で完結します。
タスク管理コンポーネント(Client Component)
// app/tasks/_components/TaskManagerClient.tsx
"use client";
import { useState } from "react";
export function TaskManagerClient({ initialTasks }: { initialTasks: Task[] }) {
// ポイント1: SC から受け取った初期データを useState で管理
const [tasks, setTasks] = useState<Task[]>(initialTasks);
const handleAdd = async (title: string) => {
const response = await fetch("/api/tasks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title }),
});
const data = await response.json();
// ポイント2: レスポンスを直接 state に反映(router.refresh() なし)
if (data.success) {
setTasks((prev) => [data.task, ...prev]);
}
};
return (/* フォームとリストの JSX */);
}
ポイントは2つだけです。
- SC から受け取った初期データをクライアント側の初期値として設定
- API レスポンスを直接 state に反映(
router.refresh()は呼ばない)
2.2 では router.refresh() によって Next.js Server への再リクエストが発生しましたが、このパターンでは API との通信のみ で完結します。Next.js Server へのリクエストは初回だけです。
このパターンのポイント: 実装のシンプルさと即時性
このパターンの本質は、従来の SPA 開発と同じ手法で実装でき、SC 再評価を待たない即時性 を得られる点にあります。
router.refresh() のような Next.js 固有の機構を利用する必要がなく、SC 再評価のオーバーヘッドもないため、レスポンスの体感速度は最も速くなります。また、setState で任意の部分だけを更新できるため、「リスト内の1件だけ更新」といった操作も効率的です。
fetch + refresh(2.2)と比較すると、SC 再評価のコストを払わずに即時性を確保できます。ただし、サーバーとの整合性はクライアント側で責任を負うことになります。
このパターンを選ぶべきシチュエーション
- 更新対象が単一コンポーネントで完結する場合(ページ内に関連するデータ表示が他になく、SC との整合性を気にしなくてよい)
- 即時性を最優先する場合(モバイル環境や低速回線での UX を重視)
- ページの一部分だけが動的な場合(「いいね」ボタン、コメント欄など、SC との整合性が問題になりにくい)
- 既存の SPA 資産を活かしたい・移行過渡期(従来のクライアント側データ管理ロジックを流用したい)
このパターンの課題
サーバーとの整合性はクライアント側の責任になる: これが最大の課題です。具体的には以下のような乖離が発生し得ます。
- ページ内の他の SC が古いまま(例: タスクを追加しても「合計: 5件」の表示は更新されない)
- ソフトナビゲーションで戻ったときにクライアント状態が失われる
- API は成功したがクライアント状態の更新に失敗し、データが不整合のまま残る
さらに、サーバー主導パターン(2.2)では router.refresh() で常にサーバーと再同期できますが、このパターンには標準的な再同期手段がありません。TanStack Query や SWR などのデータ取得ライブラリでバックグラウンド再フェッチを実装すれば緩和できますが、追加の実装が必要です。
つまり、クライアント主導を選ぶということは、即時性と引き換えに整合性の責任をクライアント側で負うという設計判断です。
ページ全体に関連するデータの更新が複雑になる: 更新したデータを複数のコンポーネントで参照する場合、トップレベルで state を保持したり Context を用意したりする必要があります。RSC の性質上、設計を工夫しないとページの大部分が Client Component になってしまうのが難点です。
App Router の機構を活かしきれない: 更新後に SC が再評価されないため、router.refresh() や revalidatePath / revalidateTag によるキャッシュ制御、Server Actions によるサーバーとの統合的な更新フローといった機構を活用しづらくなります。
まとめ
クライアント管理パターンは、従来の SPA 開発の延長として App Router を使う方式です。SC は「初期データの配信」としてのみ使い、以降は純粋な SPA として振る舞います。学習コストが低く即時性に優れる一方、App Router・RSC の利点を活かしきれないトレードオフがあります。
次のセクションでは、Server Actions を使った更新パターンを見ていきます。ここでも「サーバー主導」と「クライアント主導」という同じ構図が現れます。
3. 更新が Server Actions の場合
- では
fetchと Route Handler を使った更新パターンを見てきました。このセクションでは、 Server Actions を使った更新パターンを解説します。
Server Actions の活用により、「更新 API の定義」と「再評価トリガ」がサーバー側に統合、つまり**クライアントから見ると「関数を呼ぶだけ」**になり、エンドポイントの URL や HTTP メソッドを意識する必要がなく、再評価のトリガーもサーバー側の Action 内で完結できるようになりました。
ただし、ここでも 2. と変わらず、「SC を再評価するか、クライアント状態で完結させるか」 という選択が存在します。
- 3.1: Server Actions + SC を再評価(サーバー主導)
- 3.2: Server Actions の戻り値をクライアントキャッシュで管理(クライアント主導)
Server Actions という新しい更新手段を使っても、データの拠り所をどこに置くかという設計判断は変わりません。それぞれのパターンを見ていきましょう。
3.1 Server Actions + SC再評価 パターン
このパターンの特徴
Server Actions 内で更新処理を行うと共に refresh や revalidatePath で SC を再評価するため、最も Next.js App Router の機構を活用した設計パターンです。これまでのサーバー主導パターンと比較すると以下のようになります。
| 項目 | 2.1 PRG | 2.2 fetch + refresh | 3.1 Server Actions |
|---|---|---|---|
| 更新手段 | HTML Form + Route Handler | fetch API | Server Actions |
| 再検証 | リダイレクト | refresh() |
refresh() |
| ブラウザ-サーバー通信 | 2回 | 2回 | 1回 |
Note:
refresh(next/cache)は Server Actions 内のみで使用できる関数で、router.refresh()と同様にクライアントの Router Cache をクリアして SC を再評価します。
Server Actions + SC再評価 パターンでは、以下のようなデータフローになります。
2.2 との大きな違いは、クライアントから見ると API エンドポイントが不要になることです。Server Actions への呼び出しは Next.js が自動的に POST リクエストに変換しますが、開発者はそれを意識する必要がありません。また、データ更新と SC 再評価が同じリクエスト内で完結するため、クライアント側から再度サーバーへリクエストする必要がありません。つまりブラウザとサーバー間の通信が1度で完結します。
実装イメージ
このパターンでは、コンポーネントの役割が明確に分かれます。
- Server Component (page.tsx): データ取得と初期レンダリング
-
Server Actions: 更新処理と
refresh呼び出し - Client Component: ユーザー操作のハンドリングとローディング表示
ページコンポーネント(Server Component)
// app/tasks/page.tsx
import { TaskForm } from "./_components/TaskForm";
import { TaskList } from "./_components/TaskList";
export default async function TasksPage() {
const tasks = await getTasks();
return (
<main>
<TaskForm />
<TaskList tasks={tasks} />
</main>
);
}
Server Actions の定義
// _actions/taskActions.ts
"use server";
import { refresh } from "next/cache";
export async function addTaskAction(prevState: unknown, formData: FormData) {
const title = formData.get("title") as string;
await createTask(title);
// ポイント: Action 内で SC 再評価
refresh();
}
フォームコンポーネント(useActionState の活用)
// _components/TaskForm.tsx
"use client";
import { useActionState } from "react";
import { addTaskAction } from "../_actions/taskActions";
export function TaskForm() {
// ポイント: useActionState で Server Action の状態を管理
const [state, formAction, pending] = useActionState(addTaskAction, null);
return (
// ポイント: form action に formAction を指定
<form action={formAction}>
<input name="title" required />
<button disabled={pending}>{pending ? "追加中..." : "追加"}</button>
</form>
);
}
このパターンのポイント: Server Actions の開発体験と SC 再評価の統合
このパターンの本質は、Server Actions の開発体験と、サーバー主導の整合性を統合できる点にあります。
Server Actions を使うことで、クライアントから見ると「関数を呼ぶだけ」でデータ更新が完結します。URL や HTTP メソッドを意識する必要がなく、TypeScript の型推論も効くため型定義の二重管理が不要です。useActionState や useFormStatus と組み合わせることで、ローディング状態の管理も宣言的かつ簡潔になります。また、<form action={formAction}> の形式で書けば、JavaScript が無効な環境でもフォーム送信が動作する Progressive Enhancement も実現できます。
さらに、2.2(fetch + refresh)では 2 度のラウンドトリップが必要でしたが、このパターンでは 1 回の通信で更新と SC 再評価が完結 します。再検証のトリガー(revalidatePath / refresh)を Server Action 内に閉じ込められるため、クライアントは「いつ・何を再検証するか」を意識する必要がありません。再検証戦略の変更もサーバー側だけで完結し、クライアントコードに影響を与えません。
fetch + refresh(2.2)と比較すると、通信コストの削減と再検証ロジックのサーバー集約が得られます。クライアント管理(2.3/3.2)と比較すると、サーバーとの整合性を維持しつつ App Router の機構をフル活用できます。
このパターンを選ぶべきシチュエーション
- App Router の機能を最大限に活かしたい場合(Server Actions + キャッシュ機構で統合的なデータフローを実現)
- ページ内に複数の関連データ表示がある場合(SC 再評価で自動的に最新化、クライアント側で state を同期させる必要がない)
-
Progressive Enhancement が必要な場合(
<form action={formAction}>で JS 無効環境でも動作)
このパターンの課題
ページ全体の再評価のコスト: 2.2 の router.refresh() と同様に、ページ全体の SC 再評価が行われます。ただし、Next.jsの部分的なキャッシュを活用すれば変更があった部分だけを効率的に更新できるため、キャッシュを適切に設計することで軽減可能です。
即時性の限界: Server Action の実行 → SC 再評価という一連の処理が完了するまで UI は更新されません。ユーザーの体感として「一瞬待たされる」印象になる可能性があり、楽観的更新を組み合わせるなどのユーザビリティの観点で工夫が必要になります。
まとめ
Server Actions + SC 再評価パターンは、App Router の機構を最大限に活かせる方式です。クライアントは Action を呼ぶだけで更新処理が完結し、再検証ロジックはサーバー側に集約されます。ページ内に複数の関連データ表示がある場合に、整合性を維持するコストが最も低いパターンです。
次のセクションでは、Server Actions を使いながらもクライアント側で状態を管理するパターンを見ていきます。
3.2 Server Actions + クライアント管理パターン
3.1 Server Actions の中で、ページ全体を SC として再評価する「サーバー主導」のパターンを見ました。このセクションでは、同じく Server Actions を使いながらも、SC を再評価せずクライアント側で状態を管理する「クライアント主導」のパターンを解説します。
これは 2.3(クライアント状態で完結させる)の Server Actions 版と言えます。
このパターンの特徴
Server Actions で更新処理を行いますが、SC を再評価しません。代わりに、Action の戻り値をクライアントデータとして局所的に UI を更新します。
| 項目 | 3.1 SC再評価 | 3.2 クライアントデータ更新 |
|---|---|---|
| 更新手段 | Server Actions | Server Actions |
| SC 再評価 | する | しない |
| 戻り値の活用 | 補助的(成功/失敗の判定程度) | 主要(更新データを返す) |
2.3 パターンとの関係
2.3 では fetch + useState でクライアント状態を管理しました。このパターンはその構造を維持しつつ、更新処理だけを Server Actions に置き換えたものです。
2.3: fetch("/api/tasks", { method: "POST" }) → response.json() → setState
3.2: addTaskAction(formData) → result → setState
Server Actions を使うことで、クライアント側から見た API エンドポイントの定義が不要になります。また、startTransition と組み合わせることで、ローディング状態の管理も簡潔に書けます。
このパターンのデータフローは、3.1 と比較すると 後半が大きく異なります。
3.1 では Server Action 完了後に SC が再評価されましたが、このパターンでは Action の戻り値だけがクライアントに返され、SC 再評価は発生しません。
実装イメージ
このパターンでは、コンポーネントの役割が以下のように分かれます。
- Server Component (page.tsx): 初回データ取得のみ(以降は再評価されない)
- Server Actions: 更新処理と 戻り値でデータを返す(SC 再評価を行わない)
-
Client Component: 状態管理(
useState)とユーザー操作のハンドリング
Server Actions の定義
3.1 との最大の違いは、SC 再評価を行わないことと戻り値で更新データを返すことです。
"use server";
export async function addTaskAction(formData: FormData) {
const title = formData.get("title") as string;
const task = await createTask(title);
// 3.1 ではここで refresh() を呼んで SC 再評価を行うが、
// 3.2 では呼ばない → SC は再評価されない
// 代わりに作成したタスクを戻り値で返す
return { success: true, task };
}
タスク管理コンポーネント(Client Component)
"use client";
export function TaskManager({ initialTasks }) {
// SC から受け取った初期データを state の初期値として設定
const [tasks, setTasks] = useState(initialTasks);
const [isPending, startTransition] = useTransition();
const handleSubmit = (formData: FormData) => {
startTransition(async () => {
const result = await addTaskAction(formData);
if (result.success) {
// Action の戻り値で state を更新(router.refresh() は呼ばない)
setTasks((prev) => [result.task, ...prev]);
}
});
};
return (
<form action={handleSubmit}>
<input name="title" />
<button>{isPending ? "追加中..." : "追加"}</button>
</form>
// ... タスク一覧の表示
);
}
ポイントは以下の通りです。
-
useState(initialTasks): SC から受け取った初期データを state の初期値として設定 -
startTransition: Server Actions の呼び出しをラップし、isPendingでローディング状態を管理 -
戻り値で
setTasks: Action の結果を直接 state に反映(router.refresh()は呼ばない)
SC の役割は 初回データ取得と SSR のみです。タスクを追加・更新・削除しても、この SC は再評価されません。
このパターンのポイント: Server Actions の開発体験とクライアント主導の即時性の両立
このパターンの本質は、Server Actions の開発体験(エンドポイント不要、型安全、useTransition 等の React API 統合)と、クライアント主導の即時性(SC 再評価を待たない高速な UI 更新)を両立できる点にあります。
2.3(fetch + Route Handler)と比較すると、Server Actions による開発体験の向上が得られます。3.1(Server Actions + SC 再評価)と比較すると、SC 再評価のコストを払わずに即時性を確保できます。
このパターンを選ぶべきシチュエーション
このパターンは、以下のようなケースで有効です。
- 更新対象が単一コンポーネントで完結し、ページ全体の整合性を気にしなくてよい場合(いいねボタン、コメント欄など)
- SC 再評価のコストを払いたくないが、Server Actions の開発体験は欲しい場合(大きなページ、頻繁な更新)
- 既存のクライアント主導設計を維持しつつ、Route Handler から Server Actions へ移行したい場合
このパターンの課題
一方で、2.3 と同様のクライアント主導パターン特有の課題(サーバーとの整合性、状態の二重管理など)を抱えています。加えて、Server Actions を採用しながらも Next.jsのキャッシュ機構の活用や Progressive Enhancement といった本来の強みを活かしきれない点は認識しておく必要があります。
まとめ: 3.1 と 3.2 の使い分け
3.1 と 3.2 の選択は、「整合性」と「即時性」のトレードオフで判断できます。ページ内に複数の関連データ表示があり整合性が重要なら 3.1、更新対象が単一コンポーネントで完結し即時性を優先するなら 3.2 が適しています。
迷った場合は、まず 3.1(サーバー主導)を試し、SC 再評価のコストが問題になった時点で 3.2 への移行を検討するのが現実的です。
4. サーバー側キャッシュを踏まえた「最新化」の前提
ここまでのサーバー主導パターン(2.1 / 2.2 / 3.1)では、「ルートの Server Components が再評価されれば、最新データが表示される」という前提で説明してきました。しかし Next.js App Router にはサーバー側キャッシュ(例: Full-Route Cache / Data Cache / Cache Components など)が存在し、この前提がそのまま成立しないケースがあります。(キャッシュの詳細は以下を参照してください)
特に重要なのは、router.refresh() がサーバー側キャッシュの再検証を行わないという点です。router.refresh() は「クライアント側の Router Cache を破棄して RSC を再取得する」ためのトリガーであり、サーバー側でキャッシュされているレンダリング結果やデータ取得結果がそのまま返ってくる可能性があります。つまり、「refresh したのに更新が見えない」という状況が起こり得ます。
各パターンでの対応
2.1 PRG / 2.2 fetch + refresh の場合
これらのパターンで「常に最新のデータが表示される」ことを前提にするなら、該当ルートまたはデータ取得がキャッシュされない設定になっている必要があります。
例えば、Full-Route Cache のオプトアウトの例として以下のようにしておけば、ルート全体が動的レンダリングとなるため、SC の再評価時に常に最新のデータで UI を表示することができます。
// ルート全体をキャッシュしない
export const dynamic = "force-dynamic";
※ Data Cache や Cache Components はオプトイン機能なので、有効にしていない限り問題にはならないでしょう。
またキャッシュを活用しつつデータ更新時には最新化したい場合は、オンデマンド再検証用の Route Handler を用意し、データストア更新のタイミングでそれを呼び出すという選択肢もあります。Route Handler から revalidatePath / revalidateTag を実行することでサーバー側キャッシュを無効化でき、以降そのパス(またはタグを利用するページ)にアクセスしたタイミングで最新データで再生成されます。ただし、更新元から再検証 API を呼ぶ導線が必要になり、実装・運用の複雑性は増します。
3.1 Server Actions + SC再評価 の場合
Server Actions 内で refresh() を実行するだけではサーバー側キャッシュは再検証されません。更新後に最新データを表示するには、Server Actions 内でサーバー側キャッシュを明示的に再検証する必要があります。
- ルート全体のキャッシュを再検証:
revalidatePath() - 特定のキャッシュだけを再検証:
updateTag()/revalidateTag()
例えば、Data Cache や Cache Components で task タグを使いキャッシュを行っている場合、Server Actionsで以下のように再検証を行うことができます。
"use server";
export async function addTaskAction(formData: FormData) {
const title = formData.get("title") as string;
await createTask(title);
// サーバー側キャッシュを再検証(refresh() だけでは不十分)
updateTag("tasks");
}
キャッシュの活用
ここまでの説明は「注意点」のように見えますが、視点を変えると大きなメリットがあります。
サーバー側キャッシュを前提に設計すれば、ページ全体の SC 再評価(全データ再取得)に頼らず、必要な範囲だけを再検証してコストを下げられます。たとえば、大規模なデータを表示しているダッシュボード UI などでは各データにタグを付けておき、更新したデータのタグだけを再検証すればページ内の他のデータはキャッシュから高速に取得できます。
ただしキャッシュ設計には複雑さが伴うため、キャッシュの有用性が低い場合には force-dynamic でキャッシュを無効化し、シンプルに済ませるのも有効な選択肢です。パターン選定においては、「最新性を常に保証したいか」vs「キャッシュ効率を優先したいか」 というトレードオフを意識すると、設計判断がブレにくくなるかと思います。
まとめ
本記事では、データ更新後に UI を最新化するさまざまなパターンを見てきました。表面的には多くの選択肢があるように見えますが、結局のところ冒頭で述べたように 「データの拠り所(Single Source of Truth)をどこに置くか」 という問いに収束します。
-
サーバー主導(2.1 / 2.2 / 3.1): データの整合性を重視し、複数コンポーネント間の同期をフレームワークに任せたい場合に向いています。ページ内に関連するデータ表示が複数ある場合、SC 再評価によって一括で最新化されるのは大きな安心感があります。
-
クライアント主導(2.3 / 3.2): 即時性を重視し、局所的な更新で完結する場合に向いています。SC 再評価のコストを払わずに済むため、頻繁な更新が発生する UI や、影響範囲が明確に限定されているケースでは有効な選択肢です。
どちらが正解ということはありません。UI の要件やデータの整合性と即時性のバランス、通信コスト、プログレッシブエンハンスメントの必要性、既存の実装やアーキテクチャとの整合性など、さまざまな要素を踏まえて総合的に判断することになります。トレードオフを把握したうえで選んでいれば、後から「やっぱり別のパターンのほうがいいかも」となっても切り替えの判断がスムーズです。
また、ここで紹介したパターンはアプリ内で排他的に選ぶものではなく、組み合わせる方針でも良いかと思います。たとえば、メインのデータ更新は 3.1(Server Actions + SC 再評価)で行いつつ、いいねボタンのような頻繁で局所的な操作は 3.2(クライアント状態管理)で処理する、といった使い分けは現実的なアプローチです。
もし迷ったら、個人的にはサーバー主導から始めてみるのがいいのではないかと思っています。App Router の機構を活かしやすく整合性の問題で悩むことが少ないからです。SC 再評価のコストが実際に問題になった時点で、キャッシュ戦略の導入やクライアント主導への部分的な移行を検討するスタンスでも良いかと思います。
この記事が、Next.js App Router でのデータ更新パターンを選ぶ際の判断材料になれば幸いです。
Discussion