TOOLS BOX/ガイド/useOptimistic + Server Actions 統合パターン
Concept

useOptimistic + Server Actions 統合パターン

useOptimistic と Server Actions を組み合わせた楽観的更新フロー。Server Action の完了を待たずに UI を先に更新し、成功時は確定、失敗時は React が自動ロールバックする。useActionState パターンとの使い分けを含む実務判断を整理する。

reactnextjsuseOptimisticserver-actionsoptimistic-updaterollbackstartTransitionux

どういう場面で使うか

  • ·いいね・フォロー・削除など、高確率で成功する操作にレスポンシブな UX を持たせたいとき
  • ·Server Action の完了を待たずに UI を即時更新し、ユーザーに待ち時間を感じさせたくないとき
  • ·失敗時は自動ロールバックで元の状態に戻るだけでよい操作のとき
  • ·useActionState パターン(バリデーション・フォームエラー返し)が不要な操作のとき

注意点 / Pitfalls

  • ·applyOptimistic は startTransition の内部で呼ぶ。外で呼ぶと楽観的状態がすぐに破棄される
  • ·失敗時のロールバックは React が自動で行うが、ユーザーへのエラー通知(toast など)は別途実装が必要
  • ·サーバーが返す値(採番 ID など)に依存する操作は楽観更新と相性が悪い。仮 ID を使って後から上書きする設計が必要
  • ·useOptimistic の状態は Server Action 完了後に実際の state(props)で上書きされる。ローカル state の更新も忘れずに行う
  • ·何でも楽観更新にしてよいわけではない。決済・送金など失敗時のリカバリが複雑な操作には使わない

何と混同しやすいか

補足

useActionState パターンはバリデーション失敗を UI に返すフォーム操作向け。useOptimistic パターンは成功前提の即時フィードバックが目的で、失敗はロールバックで済む操作向け。2 つは排他ではなく、操作の性質で使い分ける。

全体フロー

ユーザー操作
  → 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 は楽観的に適用した状態を自動で破棄し、元の todosuseState の現在値)に戻す。明示的なロールバック処理は不要。

startTransition(async () => {
  applyOptimistic({ type: "toggle", id }); // 楽観的更新
  try {
    await toggleTodoAction(id);
    setTodos((prev) => /* 確定 */);   // ✅ 成功時にローカル state を更新
  } catch {
    // useOptimistic が自動ロールバック。ここでの state 操作は不要
    showErrorToast("更新に失敗しました");
  }
});

向いている場面 / 向かない場面

向いている向かない
いいね・フォロー(失敗しても軽微)決済・送金(失敗時のリカバリが複雑)
リスト項目の追加・削除サーバー採番の値に強依存する表示
表示切り替え・ステータス変更競合が頻繁に起きる共同編集
レスポンス速度より UX 優先の操作バリデーション失敗をフォームに表示する必要がある操作

useActionState パターンとの使い分け

useOptimisticuseActionState
目的即時フィードバック(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 バリデーションパターン

失敗の分類と扱い方 → エラーハンドリングの考え方

関連ドキュメント

関連サンプル

同じテーマや技術スタックを使った実装例