Day 5: Server Actions
今日学ぶこと
- Server Actionsとは何か
- "use server"ディレクティブ
- フォーム処理
- ミューテーション
- 楽観的UI更新
Server Actionsとは
Server Actionsは、サーバー上で実行される非同期関数です。フォームの送信やデータの変更(ミューテーション)をシンプルに処理できます。
flowchart LR
subgraph Client["ブラウザ"]
FORM["フォーム"]
BUTTON["ボタン"]
end
subgraph Server["サーバー"]
ACTION["Server Action"]
DB[(データベース)]
end
FORM -->|"submit"| ACTION
BUTTON -->|"click"| ACTION
ACTION --> DB
style Client fill:#22c55e,color:#fff
style Server fill:#3b82f6,color:#fff
Server Actionsの特徴
| 特徴 | 説明 |
|---|---|
| サーバーで実行 | 機密データを安全に扱える |
| Progressive Enhancement | JSなしでも動作 |
| シンプル | APIルートを書く必要がない |
| 自動キャッシュ無効化 | revalidatePathと連携 |
"use server"ディレクティブ
Server Actionsを定義するには、"use server"を使います。
ファイル全体をServer Actionsにする
// src/app/actions.ts
"use server";
export async function createPost(formData: FormData) {
const title = formData.get("title") as string;
const content = formData.get("content") as string;
await db.post.create({
data: { title, content },
});
}
export async function deletePost(id: string) {
await db.post.delete({
where: { id },
});
}
関数単位で定義する
// src/app/blog/page.tsx
export default function BlogPage() {
async function handleSubmit(formData: FormData) {
"use server";
const title = formData.get("title") as string;
// サーバーで実行される処理
}
return (
<form action={handleSubmit}>
<input name="title" />
<button type="submit">作成</button>
</form>
);
}
フォーム処理の基本
シンプルなフォーム
// src/app/contact/page.tsx
import { redirect } from "next/navigation";
async function submitContact(formData: FormData) {
"use server";
const name = formData.get("name") as string;
const email = formData.get("email") as string;
const message = formData.get("message") as string;
// データを保存
await db.contact.create({
data: { name, email, message },
});
// 完了ページにリダイレクト
redirect("/contact/thanks");
}
export default function ContactPage() {
return (
<form action={submitContact} className="max-w-md mx-auto p-4">
<div className="mb-4">
<label className="block mb-1">お名前</label>
<input
name="name"
required
className="w-full border rounded p-2"
/>
</div>
<div className="mb-4">
<label className="block mb-1">メールアドレス</label>
<input
name="email"
type="email"
required
className="w-full border rounded p-2"
/>
</div>
<div className="mb-4">
<label className="block mb-1">メッセージ</label>
<textarea
name="message"
required
rows={4}
className="w-full border rounded p-2"
/>
</div>
<button
type="submit"
className="w-full bg-blue-600 text-white py-2 rounded"
>
送信
</button>
</form>
);
}
フォームの流れ
sequenceDiagram
participant User as ユーザー
participant Form as フォーム
participant Action as Server Action
participant DB as データベース
User->>Form: 入力・送信
Form->>Action: FormDataを送信
Action->>DB: データを保存
DB-->>Action: 完了
Action-->>Form: リダイレクト
Form-->>User: 完了ページを表示
useFormStatusでローディング状態
useFormStatusフックを使って、フォーム送信中の状態を取得できます。
// src/components/SubmitButton.tsx
"use client";
import { useFormStatus } from "react-dom";
export function SubmitButton({ children }: { children: React.ReactNode }) {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className={`w-full py-2 rounded text-white ${
pending ? "bg-gray-400" : "bg-blue-600 hover:bg-blue-700"
}`}
>
{pending ? "送信中..." : children}
</button>
);
}
// src/app/contact/page.tsx
import { SubmitButton } from "@/components/SubmitButton";
export default function ContactPage() {
return (
<form action={submitContact}>
{/* フォームフィールド */}
<SubmitButton>送信</SubmitButton>
</form>
);
}
注意:
useFormStatusは、<form>の子孫コンポーネントでのみ動作します。
useActionStateでバリデーション
useActionStateを使って、Server Actionの結果(エラーメッセージなど)を管理できます。
// src/app/actions.ts
"use server";
type ActionState = {
error?: string;
success?: boolean;
};
export async function createAccount(
prevState: ActionState,
formData: FormData
): Promise<ActionState> {
const email = formData.get("email") as string;
const password = formData.get("password") as string;
// バリデーション
if (!email.includes("@")) {
return { error: "有効なメールアドレスを入力してください" };
}
if (password.length < 8) {
return { error: "パスワードは8文字以上必要です" };
}
// アカウント作成
try {
await db.user.create({
data: { email, password: hashPassword(password) },
});
return { success: true };
} catch {
return { error: "アカウントの作成に失敗しました" };
}
}
// src/app/signup/page.tsx
"use client";
import { useActionState } from "react";
import { createAccount } from "@/app/actions";
import { SubmitButton } from "@/components/SubmitButton";
export default function SignupPage() {
const [state, formAction] = useActionState(createAccount, {});
return (
<form action={formAction} className="max-w-md mx-auto p-4">
{state.error && (
<div className="mb-4 p-3 bg-red-100 text-red-600 rounded">
{state.error}
</div>
)}
{state.success && (
<div className="mb-4 p-3 bg-green-100 text-green-600 rounded">
アカウントが作成されました!
</div>
)}
<div className="mb-4">
<label className="block mb-1">メールアドレス</label>
<input
name="email"
type="email"
required
className="w-full border rounded p-2"
/>
</div>
<div className="mb-4">
<label className="block mb-1">パスワード</label>
<input
name="password"
type="password"
required
className="w-full border rounded p-2"
/>
</div>
<SubmitButton>アカウント作成</SubmitButton>
</form>
);
}
キャッシュの再検証
データを変更した後、関連するページのキャッシュを無効化します。
// src/app/actions.ts
"use server";
import { revalidatePath, revalidateTag } from "next/cache";
export async function createPost(formData: FormData) {
const title = formData.get("title") as string;
const content = formData.get("content") as string;
await db.post.create({
data: { title, content },
});
// 方法1: パスを再検証
revalidatePath("/blog");
// 方法2: タグを再検証
revalidateTag("posts");
}
export async function deletePost(id: string) {
await db.post.delete({
where: { id },
});
revalidatePath("/blog");
}
flowchart TB
subgraph Action["Server Action"]
CREATE["createPost()"]
REVALIDATE["revalidatePath('/blog')"]
end
subgraph Cache["キャッシュ"]
OLD["古いデータ"]
NEW["新しいデータ"]
end
CREATE --> REVALIDATE
REVALIDATE -->|"無効化"| OLD
OLD -->|"次回アクセス時"| NEW
style Action fill:#3b82f6,color:#fff
style Cache fill:#22c55e,color:#fff
フォーム外でのServer Action
ボタンクリックなど、フォーム以外からもServer Actionsを呼び出せます。
// src/app/actions.ts
"use server";
export async function incrementLike(postId: string) {
await db.post.update({
where: { id: postId },
data: { likes: { increment: 1 } },
});
revalidatePath(`/blog/${postId}`);
}
// src/components/LikeButton.tsx
"use client";
import { incrementLike } from "@/app/actions";
import { useTransition } from "react";
export function LikeButton({ postId }: { postId: string }) {
const [isPending, startTransition] = useTransition();
const handleClick = () => {
startTransition(() => {
incrementLike(postId);
});
};
return (
<button
onClick={handleClick}
disabled={isPending}
className="flex items-center gap-2"
>
{isPending ? "..." : "❤️"} いいね
</button>
);
}
楽観的UI更新
useOptimisticを使って、サーバーの応答を待たずにUIを更新できます。
// src/components/LikeButton.tsx
"use client";
import { incrementLike } from "@/app/actions";
import { useOptimistic, useTransition } from "react";
export function LikeButton({
postId,
initialLikes,
}: {
postId: string;
initialLikes: number;
}) {
const [isPending, startTransition] = useTransition();
const [optimisticLikes, addOptimisticLike] = useOptimistic(
initialLikes,
(state) => state + 1
);
const handleClick = () => {
startTransition(async () => {
addOptimisticLike(null); // 即座にUIを更新
await incrementLike(postId); // サーバーで実行
});
};
return (
<button
onClick={handleClick}
disabled={isPending}
className="flex items-center gap-2"
>
❤️ {optimisticLikes}
</button>
);
}
sequenceDiagram
participant User as ユーザー
participant UI as UI
participant Server as サーバー
User->>UI: いいねボタンをクリック
UI->>UI: 即座にカウント+1(楽観的更新)
UI->>Server: incrementLike()を呼び出し
Note over User: すぐに反応が見える
Server-->>UI: 完了
Note over UI: 実際の値で置き換え
実践: Todoアプリ
// src/app/actions.ts
"use server";
import { revalidatePath } from "next/cache";
export async function addTodo(formData: FormData) {
const title = formData.get("title") as string;
await db.todo.create({
data: { title, completed: false },
});
revalidatePath("/todos");
}
export async function toggleTodo(id: string) {
const todo = await db.todo.findUnique({ where: { id } });
await db.todo.update({
where: { id },
data: { completed: !todo?.completed },
});
revalidatePath("/todos");
}
export async function deleteTodo(id: string) {
await db.todo.delete({ where: { id } });
revalidatePath("/todos");
}
// src/app/todos/page.tsx
import { addTodo, toggleTodo, deleteTodo } from "@/app/actions";
async function getTodos() {
return db.todo.findMany({ orderBy: { createdAt: "desc" } });
}
export default async function TodosPage() {
const todos = await getTodos();
return (
<div className="max-w-md mx-auto p-4">
<h1 className="text-2xl font-bold mb-4">Todo List</h1>
{/* 追加フォーム */}
<form action={addTodo} className="mb-4 flex gap-2">
<input
name="title"
required
placeholder="新しいタスク..."
className="flex-1 border rounded p-2"
/>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded"
>
追加
</button>
</form>
{/* Todoリスト */}
<ul className="space-y-2">
{todos.map((todo) => (
<li
key={todo.id}
className="flex items-center gap-2 p-2 border rounded"
>
<form action={toggleTodo.bind(null, todo.id)}>
<button type="submit">
{todo.completed ? "✅" : "⬜"}
</button>
</form>
<span className={todo.completed ? "line-through" : ""}>
{todo.title}
</span>
<form
action={deleteTodo.bind(null, todo.id)}
className="ml-auto"
>
<button type="submit" className="text-red-500">
🗑️
</button>
</form>
</li>
))}
</ul>
</div>
);
}
まとめ
| 概念 | 説明 |
|---|---|
| Server Actions | サーバーで実行される非同期関数 |
| "use server" | Server Actionsを定義するディレクティブ |
| useFormStatus | フォーム送信中の状態を取得 |
| useActionState | アクションの結果を管理 |
| useOptimistic | 楽観的UI更新 |
重要ポイント
- APIルート不要: Server Actionsで直接データを操作
- Progressive Enhancement: JSなしでも動作
- 自動再検証: revalidatePathで簡単にキャッシュ更新
- 楽観的更新: useOptimisticで即座にUI反映
練習問題
問題1: 基本
コメント投稿フォームを作成してください。送信中はボタンを無効化し、「送信中...」と表示してください。
問題2: 応用
バリデーション付きのユーザー登録フォームを作成してください。エラーメッセージを表示し、成功時はリダイレクトしてください。
チャレンジ問題
いいねボタンを楽観的更新で実装してください。ボタンをクリックするとすぐにカウントが増え、サーバーで処理が完了したら実際の値に置き換わるようにしてください。
参考リンク
次回予告: Day 6では「画像・フォント・メタデータ最適化」について学びます。next/image、next/font、そしてSEO対策について探求します。