レビュー済み·難易度: 中級·更新: 2026-04-18

React 19 の useOptimistic で楽観的 UI 更新を実装する

React 19 の useOptimistic を使い、Server Actions の完了を待たずに UI を即時更新し、失敗時に自動ロールバックする楽観的更新を実装する例。

nextjsui-componentperformance

対応バージョン

nextjs 15react 19

前提環境

Next.js Server Actions の基本と React の useState を理解していること

概要

React 19 の useOptimistic を使い、Server Actions の完了を待たずに UI を即時更新する楽観的更新を実装する。Server Action が成功すれば確定、失敗すれば自動ロールバック。tanstack-query-optimistic-update は TanStack Query 経由だが、これは React 19 ネイティブで Server Actions と直接組み合わせる。

インストール

# 追加インストールは不要

実装

Server Actions

// app/todos/actions.ts
"use server";

import { revalidatePath } from "next/cache";

// 実際のアプリでは DB に保存する
const serverTodos: { id: number; text: string; done: boolean }[] = [
  { id: 1, text: "牛乳を買う", done: false },
  { id: 2, text: "メールを返す", done: false },
];

export async function getTodos() {
  return serverTodos;
}

export async function toggleTodo(id: number) {
  // ネットワーク遅延をシミュレート
  await new Promise((r) => setTimeout(r, 800));

  const todo = serverTodos.find((t) => t.id === id);
  if (!todo) throw new Error("Not found");
  todo.done = !todo.done;

  revalidatePath("/todos");
}

export async function addTodo(text: string) {
  await new Promise((r) => setTimeout(r, 800));

  const newTodo = { id: Date.now(), text, done: false };
  serverTodos.push(newTodo);
  revalidatePath("/todos");
  return newTodo;
}

Todo リストコンポーネント

// app/todos/TodoList.tsx
"use client";

import { useOptimistic, useState, useTransition } from "react";
import { addTodo, toggleTodo } 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();

  // useOptimistic: 楽観的な状態と適用関数を返す
  const [optimisticTodos, applyOptimistic] = useOptimistic(
    todos,
    (current, action: OptimisticAction) => {
      if (action.type === "toggle") {
        return current.map((t) =>
          t.id === action.id ? { ...t, done: !t.done } : t
        );
      }
      if (action.type === "add") {
        return [...current, action.todo];
      }
      return current;
    }
  );

  function handleToggle(id: number) {
    startTransition(async () => {
      // 即時に UI を更新(Server Action 完了前)
      applyOptimistic({ type: "toggle", id });
      try {
        await toggleTodo(id);
        // 成功後: サーバーの最新状態で確定
        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;
    if (!text.trim()) return;

    const tempTodo: Todo = { id: Date.now(), text, done: false };

    startTransition(async () => {
      applyOptimistic({ type: "add", todo: tempTodo });
      try {
        const newTodo = await addTodo(text);
        setTodos((prev) => [...prev, newTodo]);
      } catch {
        // 失敗時は自動ロールバック
      }
    });
  }

  return (
    <div className="mx-auto max-w-md p-8">
      <h1 className="mb-6 text-2xl font-bold">Todo リスト</h1>

      <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/page.tsx
import { getTodos } from "./actions";
import { TodoList } from "./TodoList";

export default async function TodosPage() {
  const todos = await getTodos();
  return <TodoList initialTodos={todos} />;
}

ポイント

  • useOptimistic(state, reducer) は現在の state と楽観的な更新関数を受け取り、[楽観的なstate, apply関数] を返す
  • applyOptimistic(action) を呼ぶと、reducer が即座に UI 上の state を更新する。Server Action の完了を待たない
  • Server Action が失敗した場合、useOptimistic は自動的に元の state に戻す。明示的なロールバック処理は不要
  • startTransition でラップすることで、Server Action 中に isPendingtrue になり、多重送信を防げる
  • tanstack-query-optimistic-update との違い: ライブラリなしで Server Actions と直接組み合わせられる。React 19 + Next.js 15 の組み合わせで最もシンプルな楽観的更新パターン

注意点

tanstack-query-optimistic-update は TanStack Query の onMutate を使う。こちらは React 19 ネイティブの useOptimistic で Server Actions と直接組み合わせる。

関連サンプル