全体フロー
ユーザー操作
→ applyOptimistic() で UI を即時更新(Server Action の呼び出し前)
→ startTransition 内で Server Action を非同期実行
├── 成功 → ローカル state を確定(setXxx でサーバーの結果に合わせる)
└── 失敗 → useOptimistic が元の state に自動ロールバック
→ エラー通知はアプリ側で別途実装(toast など)
基本実装パターン
// app/todos/TodoList.tsx
"use client";
import { useOptimistic, useState, useTransition } from "react";
import { toggleTodoAction, addTodoAction } from "./actions";
type Todo = { id: number; text: string; done: boolean };
type OptimisticAction =
| { type: "toggle"; id: number }
| { type: "add"; todo: Todo };
export function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
const [todos, setTodos] = useState(initialTodos);
const [isPending, startTransition] = useTransition();
const [optimisticTodos, applyOptimistic] = useOptimistic(
todos,
(current, action: OptimisticAction) => {
switch (action.type) {
case "toggle":
return current.map((t) =>
t.id === action.id ? { ...t, done: !t.done } : t
);
case "add":
return [...current, action.todo];
default:
return current;
}
}
);
function handleToggle(id: number) {
startTransition(async () => {
applyOptimistic({ type: "toggle", id }); // 先に UI を更新
try {
await toggleTodoAction(id);
// 成功: ローカル state をサーバーの結果に確定させる
setTodos((prev) =>
prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t))
);
} catch {
// 失敗: useOptimistic が自動でロールバック。エラー通知は別途実装
}
});
}
async function handleAdd(formData: FormData) {
const text = (formData.get("text") as string).trim();
if (!text) return;
// 仮 ID で楽観的に追加(サーバーが採番した ID は後から上書き)
const tempId = Date.now();
const tempTodo: Todo = { id: tempId, text, done: false };
startTransition(async () => {
applyOptimistic({ type: "add", todo: tempTodo });
try {
const saved = await addTodoAction(text);
// 仮 ID を実 ID に差し替え
setTodos((prev) => [
...prev.filter((t) => t.id !== tempId),
saved,
]);
} catch {
// 失敗: 仮エントリは自動ロールバックされる
}
});
}
return (
<div className="mx-auto max-w-md p-8">
<form action={handleAdd} className="mb-6 flex gap-2">
<input
name="text"
placeholder="新しいタスク..."
className="flex-1 rounded border px-3 py-2 text-sm"
/>
<button
type="submit"
disabled={isPending}
className="rounded bg-blue-600 px-4 py-2 text-sm text-white disabled:opacity-50"
>
追加
</button>
</form>
<ul className="space-y-2">
{optimisticTodos.map((todo) => (
<li key={todo.id} className="flex items-center gap-3 rounded border p-3">
<button
onClick={() => handleToggle(todo.id)}
disabled={isPending}
className={`h-5 w-5 rounded-full border-2 transition-colors ${
todo.done ? "border-green-500 bg-green-500" : "border-gray-300"
}`}
/>
<span
className={`flex-1 text-sm ${
todo.done ? "text-gray-400 line-through" : "text-gray-800"
}`}
>
{todo.text}
</span>
</li>
))}
</ul>
</div>
);
}
// app/todos/actions.ts
"use server";
import { revalidatePath } from "next/cache";
type Todo = { id: number; text: string; done: boolean };
const serverTodos: Todo[] = [
{ id: 1, text: "牛乳を買う", done: false },
{ id: 2, text: "メールを返す", done: false },
];
export async function toggleTodoAction(id: number) {
await new Promise((r) => setTimeout(r, 600));
const todo = serverTodos.find((t) => t.id === id);
if (!todo) throw new Error("Not found");
todo.done = !todo.done;
revalidatePath("/todos");
}
export async function addTodoAction(text: string): Promise<Todo> {
await new Promise((r) => setTimeout(r, 600));
const newTodo: Todo = { id: Date.now(), text, done: false };
serverTodos.push(newTodo);
revalidatePath("/todos");
return newTodo;
}
成功時の確定 / 失敗時のロールバック
成功時 — Server Action が正常に完了したら、ローカル state(useState)を実際の結果で確定する。useOptimistic の表示はこの state を参照するため、自動的に確定状態に切り替わる。
失敗時 — Server Action がエラーになると、useOptimistic は楽観的に適用した状態を自動で破棄し、元の todos(useState の現在値)に戻す。明示的なロールバック処理は不要。
startTransition(async () => {
applyOptimistic({ type: "toggle", id }); // 楽観的更新
try {
await toggleTodoAction(id);
setTodos((prev) => /* 確定 */); // ✅ 成功時にローカル state を更新
} catch {
// useOptimistic が自動ロールバック。ここでの state 操作は不要
showErrorToast("更新に失敗しました");
}
});
向いている場面 / 向かない場面
| 向いている | 向かない |
|---|---|
| いいね・フォロー(失敗しても軽微) | 決済・送金(失敗時のリカバリが複雑) |
| リスト項目の追加・削除 | サーバー採番の値に強依存する表示 |
| 表示切り替え・ステータス変更 | 競合が頻繁に起きる共同編集 |
| レスポンス速度より UX 優先の操作 | バリデーション失敗をフォームに表示する必要がある操作 |
useActionState パターンとの使い分け
| useOptimistic | useActionState | |
|---|---|---|
| 目的 | 即時フィードバック(UX 優先) | フォームエラーの UI 返し |
| 失敗時 | 自動ロールバック | エラーを state で受け取り表示 |
| 向く操作 | いいね・削除・ステータス切替 | 新規作成フォーム・バリデーション必須の更新 |
| バリデーション | 最小限(クライアント側のみでも可) | Zod などで詳細エラーを返す |
| ロールバック | React が自動処理 | 明示的な UI 更新が必要 |
2 つは排他ではなく、操作の性質で選ぶ。
- 「成功前提・失敗はロールバックでよい」 →
useOptimistic - 「失敗時のエラーをフォームに表示して修正させたい」 →
useActionState
よくある誤用
applyOptimistic を startTransition の外で呼ぶ
// ❌ startTransition の外で呼ぶと楽観的状態がすぐに消える
applyOptimistic({ type: "toggle", id });
startTransition(async () => {
await toggleTodoAction(id);
});
// ✅ startTransition の内部で呼ぶ
startTransition(async () => {
applyOptimistic({ type: "toggle", id }); // ここで呼ぶ
await toggleTodoAction(id);
});
ローカル state の更新を忘れる
// ❌ Server Action 成功後に setTodos を呼ばないと、次の遷移後に古い状態に戻る
startTransition(async () => {
applyOptimistic({ type: "toggle", id });
await toggleTodoAction(id);
// setTodos(...) を書き忘れ → リフレッシュ後に元の状態に戻る
});
// ✅ 成功したら必ずローカル state を確定させる
startTransition(async () => {
applyOptimistic({ type: "toggle", id });
await toggleTodoAction(id);
setTodos((prev) => prev.map((t) => t.id === id ? { ...t, done: !t.done } : t));
});
何でも楽観更新にする
楽観更新はサーバーに確認する前に UI を変える設計です。失敗時のロールバックで済まない操作(決済・在庫確保・権限付与など)には使いません。「失敗したら UI が元に戻るだけでよい」が成立する操作に絞ってください。
useOptimistic の API 詳細 → useOptimistic
Server Actions の基本 → Server Actions
バリデーション付き更新フロー → Server Actions + Zod バリデーションパターン
失敗の分類と扱い方 → エラーハンドリングの考え方