概要
Server Actions と revalidatePath を使い、追加・更新・削除を含む CRUD 操作を実装する。API ルートを作らずにフォームやボタンから直接サーバー処理を呼び出す。nextjs-route-handler-crud との違いは、fetch を介さず関数を直接呼び出すこと。
インストール
# 追加インストールは不要
実装
型定義とインメモリストア
// lib/todoStore.ts
export type Todo = {
id: number;
title: string;
completed: boolean;
};
let todos: Todo[] = [
{ id: 1, title: "買い物をする", completed: false },
{ id: 2, title: "メールを返信する", completed: true },
];
let nextId = 3;
export const todoStore = {
getAll: () => [...todos],
create: (title: string): Todo => {
const todo = { id: nextId++, title, completed: false };
todos.push(todo);
return todo;
},
toggle: (id: number): boolean => {
const todo = todos.find((t) => t.id === id);
if (!todo) return false;
todo.completed = !todo.completed;
return true;
},
delete: (id: number): boolean => {
const before = todos.length;
todos = todos.filter((t) => t.id !== id);
return todos.length < before;
},
};
Server Actions
// app/todos/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { todoStore } from "@/lib/todoStore";
export async function createTodo(formData: FormData) {
const title = formData.get("title");
if (typeof title !== "string" || !title.trim()) return;
todoStore.create(title.trim());
revalidatePath("/todos");
}
export async function toggleTodo(id: number) {
todoStore.toggle(id);
revalidatePath("/todos");
}
export async function deleteTodo(id: number) {
todoStore.delete(id);
revalidatePath("/todos");
}
ページコンポーネント(Server Component)
// app/todos/page.tsx
import { todoStore } from "@/lib/todoStore";
import { createTodo, deleteTodo, toggleTodo } from "./actions";
export default function TodosPage() {
const todos = todoStore.getAll();
return (
<main className="mx-auto max-w-md p-8">
<h1 className="mb-6 text-2xl font-bold">Todo リスト</h1>
{/* 追加フォーム */}
<form action={createTodo} className="mb-6 flex gap-2">
<input
name="title"
type="text"
placeholder="新しいタスク..."
required
className="flex-1 rounded border px-3 py-2 text-sm"
/>
<button
type="submit"
className="rounded bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700"
>
追加
</button>
</form>
{/* Todo リスト */}
<ul className="space-y-2">
{todos.map((todo) => (
<li
key={todo.id}
className="flex items-center gap-3 rounded border p-3"
>
{/* 完了切り替え */}
<form action={toggleTodo.bind(null, todo.id)}>
<button type="submit" className="flex-shrink-0">
<span
className={`inline-block h-5 w-5 rounded-full border-2 ${
todo.completed
? "border-green-500 bg-green-500"
: "border-gray-300"
}`}
/>
</button>
</form>
<span
className={`flex-1 text-sm ${
todo.completed ? "text-gray-400 line-through" : "text-gray-800"
}`}
>
{todo.title}
</span>
{/* 削除 */}
<form action={deleteTodo.bind(null, todo.id)}>
<button
type="submit"
className="text-sm text-red-400 hover:text-red-600"
>
削除
</button>
</form>
</li>
))}
</ul>
</main>
);
}
ポイント
'use server'ディレクティブを付けた関数が Server Actions になる。クライアントから直接呼び出せるrevalidatePath("/todos")でそのパスのキャッシュを無効化し、次のリクエストで最新データを再取得させる<form action={serverAction}>で HTML ネイティブのフォーム送信として動作するため、JavaScript が無効な環境でも機能するserverAction.bind(null, id)で引数を渡す。これはaction属性にそのまま渡せる唯一のパターンnextjs-route-handler-crudとの使い分け: 外部 API 不要で Next.js 内で完結する CRUD なら Server Actions の方がシンプル