概要
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 中にisPendingがtrueになり、多重送信を防げるtanstack-query-optimistic-updateとの違い: ライブラリなしで Server Actions と直接組み合わせられる。React 19 + Next.js 15 の組み合わせで最もシンプルな楽観的更新パターン