失敗を 2 種類に分ける
Next.js / React アプリの失敗は 期待される失敗 と 予期しない失敗 の 2 種類に分けることが判断の出発点です。
| 期待される失敗 | 予期しない失敗 | |
|---|---|---|
| 例 | バリデーションエラー・認証失敗・not found | DB 障害・外部 API 障害・プログラムのバグ |
| 起因 | ユーザー操作・ビジネスルール | システム側の問題 |
| ユーザーは回復できるか | できる(入力を直すなど) | できない |
| 扱い方 | return { error } で UI に返す | throw して error.tsx にキャッチさせる |
期待される失敗 — UI に返す
バリデーションエラーや認証失敗のように、ユーザーが操作を修正すれば解決できる失敗は、エラーメッセージとして UI に返します。
// Server Action
"use server";
export async function loginAction(
_prev: { error?: string } | null,
formData: FormData
) {
const email = formData.get("email") as string;
const password = formData.get("password") as string;
// バリデーション(期待される失敗)
if (!email || !password) {
return { error: "メールアドレスとパスワードを入力してください" };
}
const ok = await authenticate(email, password);
if (!ok) {
return { error: "メールアドレスまたはパスワードが正しくありません" };
}
redirect("/dashboard");
}
// Client Component(useActionState で状態を受け取る)
"use client";
import { useActionState } from "react";
import { loginAction } from "./actions";
export function LoginForm() {
const [state, action, isPending] = useActionState(loginAction, null);
return (
<form action={action}>
{state?.error && (
<p className="text-sm text-red-500">{state.error}</p>
)}
<input name="email" type="email" required />
<input name="password" type="password" required />
<button disabled={isPending}>ログイン</button>
</form>
);
}
予期しない失敗 — throw して error.tsx にキャッチさせる
DB 障害や外部 API の失敗など、ユーザーが回復できないエラーは throw します。Next.js の error.tsx がキャッチして、フォールバック UI を表示します。
// Server Action(DB エラーは throw させる)
"use server";
export async function createPostAction(formData: FormData) {
const title = formData.get("title") as string;
if (!title) {
return { error: "タイトルを入力してください" }; // 期待される失敗 → 返す
}
// DB エラーは catch せずに throw させる → error.tsx がキャッチ
await db.post.create({ data: { title } });
revalidatePath("/posts");
redirect("/posts");
}
// app/posts/error.tsx
"use client"; // error.tsx は必ず Client Component
import { useEffect } from "react";
export default function ErrorPage({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
useEffect(() => {
console.error(error); // エラーを外部サービスに送信するなど
}, [error]);
return (
<div>
<h2>問題が発生しました</h2>
<button onClick={reset}>再試行</button>
</div>
);
}
not-found — リソースが存在しない場合
データベースのレコードが存在しない場合は notFound() を呼んで 404 ページを表示します。エラーではなく「存在しない」という正常な応答です。
// app/posts/[slug]/page.tsx
import { notFound } from "next/navigation";
export default async function PostPage({ params }: { params: { slug: string } }) {
const post = await db.post.findUnique({ where: { slug: params.slug } });
if (!post) {
notFound(); // → app/not-found.tsx を表示
}
return <PostDetail post={post} />;
}
// app/not-found.tsx(任意でカスタマイズ)
export default function NotFound() {
return (
<div>
<h2>ページが見つかりません</h2>
<a href="/">トップへ戻る</a>
</div>
);
}
Server Actions でのエラー区分まとめ
Server Action 内の失敗
├── バリデーションエラー・ビジネスルール違反
│ └── return { error: "..." } ← useActionState の state で受け取る
├── リソースが存在しない
│ └── notFound() ← not-found.tsx を表示
├── 認証失敗(ページ遷移が必要な場合)
│ └── redirect("/login") ← try/catch の外で呼ぶ
└── DB 障害・外部 API 障害・予期しない例外
└── throw(または catch せず伝播)← error.tsx がキャッチ
よくある誤用
すべてを try/catch で握りつぶす
// ❌ DB エラーも return で返すと、システム障害がサイレントになる
export async function createPost(formData: FormData) {
try {
await db.post.create({ ... });
} catch (e) {
return { error: "失敗しました" }; // DB 障害も同じ扱いになってしまう
}
}
// ✅ 期待される失敗だけ catch して返す。DB エラーは throw させる
export async function createPost(formData: FormData) {
const title = formData.get("title") as string;
if (!title) return { error: "タイトルを入力してください" };
// DB エラーは伝播させて error.tsx にキャッチさせる
await db.post.create({ data: { title } });
}
redirect() を try/catch に入れる
// ❌ redirect() の throw が catch に捕まってリダイレクトされない
try {
await doSomething();
redirect("/done");
} catch (e) {
return { error: "失敗" }; // redirect の NEXT_REDIRECT もここに入る
}
// ✅ redirect() は try/catch の外
try {
await doSomething();
} catch (e) {
return { error: "失敗" };
}
redirect("/done");
useActionState でエラーを状態として受け取る方法は → useActionState
Server Actions の更新フロー全体は → Server Actions