全体の流れ
フォーム送信
→ Server Action 呼び出し(useActionState 経由)
→ Zod safeParse でバリデーション
├── 失敗 → return { errors } でフォームに返す
└── 成功 → DB 処理
├── DB エラー → throw(error.tsx がキャッチ)
└── 成功 → revalidatePath → redirect()
Zod スキーマ定義
// lib/schemas/post.ts
import { z } from "zod";
export const createPostSchema = z.object({
title: z
.string()
.min(1, "タイトルを入力してください")
.max(100, "タイトルは100文字以内にしてください"),
body: z
.string()
.min(10, "本文は10文字以上入力してください"),
});
export type CreatePostInput = z.infer<typeof createPostSchema>;
状態の型設計
useActionState で受け取る状態の型を定義します。フィールドごとのエラーと、フォーム全体のエラーを分けておくと扱いやすいです。
// フィールドエラー: Zod の fieldErrors 形式に合わせる
// フォームエラー: 認証失敗など、特定フィールドに紐づかないエラー
type ActionState = {
errors?: {
title?: string[];
body?: string[];
};
formError?: string;
} | null;
Server Action の実装
// app/posts/actions.ts
"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { createPostSchema, type ActionState } from "@/lib/schemas/post";
export async function createPostAction(
_prevState: ActionState,
formData: FormData
): Promise<ActionState> {
// 1. safeParse でバリデーション(失敗は状態として返す)
const result = createPostSchema.safeParse({
title: formData.get("title"),
body: formData.get("body"),
});
if (!result.success) {
return {
errors: result.error.flatten().fieldErrors,
};
}
// 2. DB 処理(エラーは throw させて error.tsx に任せる)
await db.post.create({ data: result.data });
// 3. 成功:キャッシュ更新 → リダイレクト(try/catch の外)
revalidatePath("/posts");
redirect("/posts");
}
フォームコンポーネント(useActionState で受け取る)
// components/CreatePostForm.tsx
"use client";
import { useActionState } from "react";
import { createPostAction } from "./actions";
export function CreatePostForm() {
const [state, action, isPending] = useActionState(createPostAction, null);
return (
<form action={action} className="space-y-4">
{/* フォーム全体エラー */}
{state?.formError && (
<p className="rounded bg-red-50 px-3 py-2 text-sm text-red-600">
{state.formError}
</p>
)}
<div>
<label htmlFor="title">タイトル</label>
<input
id="title"
name="title"
className="block w-full border px-3 py-2"
aria-describedby={state?.errors?.title ? "title-error" : undefined}
/>
{/* フィールドエラー */}
{state?.errors?.title && (
<p id="title-error" className="mt-1 text-sm text-red-500">
{state.errors.title[0]}
</p>
)}
</div>
<div>
<label htmlFor="body">本文</label>
<textarea
id="body"
name="body"
rows={5}
className="block w-full border px-3 py-2"
aria-describedby={state?.errors?.body ? "body-error" : undefined}
/>
{state?.errors?.body && (
<p id="body-error" className="mt-1 text-sm text-red-500">
{state.errors.body[0]}
</p>
)}
</div>
<button type="submit" disabled={isPending}>
{isPending ? "送信中…" : "作成"}
</button>
</form>
);
}
parse vs safeParse の使い分け
parse() | safeParse() | |
|---|---|---|
| 失敗時の挙動 | 例外を throw | { success: false, error } を返す |
| 向いている場面 | 想定外のデータ(外部 API・設定ファイル) | ユーザー入力のバリデーション |
| Server Action での推奨 | 使わない | 使う |
フォーム入力は「バリデーション失敗は想定内」のため safeParse を使います。parse を使うとバリデーション失敗が例外になり、フォームエラーとして返せなくなります。
redirect() の置き場所
// ❌ try/catch の中に redirect() を書くとキャッチされてリダイレクトされない
export async function createPostAction(_prev: ActionState, formData: FormData) {
try {
const result = schema.safeParse(Object.fromEntries(formData));
if (!result.success) return { errors: result.error.flatten().fieldErrors };
await db.post.create({ data: result.data });
revalidatePath("/posts");
redirect("/posts"); // ← NEXT_REDIRECT が catch に入る
} catch (e) {
return { formError: "失敗しました" };
}
}
// ✅ redirect() は try/catch の外に出す
export async function createPostAction(_prev: ActionState, formData: FormData) {
const result = schema.safeParse(Object.fromEntries(formData));
if (!result.success) return { errors: result.error.flatten().fieldErrors };
// DB エラーは throw させる(try/catch なし)
await db.post.create({ data: result.data });
revalidatePath("/posts");
redirect("/posts"); // try の外
}
期待される失敗と予期しない失敗の境界
フォーム入力バリデーション失敗 → return { errors } ← safeParse で分離
認証失敗・権限不足 → return { formError } ← 明示的な条件チェックで返す
DB エラー・接続失敗 → throw(伝播) ← error.tsx がキャッチ
リソースが存在しない → notFound() ← 404 ページを表示
期待される失敗と予期しない失敗の考え方 → エラーハンドリングの考え方
useActionState の詳細 → useActionState
Server Actions の更新フロー全体 → Server Actions