はじめに
React 19 で登場した useActionState と Next.js の Server Actions を組み合わせることで、フォーム送信の「入力チェック → 重複チェック → DB 登録 → 完了ページへの移動」を一貫した形でシンプルに実装できます。
本記事では、useActionState と Server Actions の使い方を解説しながら、「お問い合わせフォーム」を作成していきます。
使用する技術は以下の通りです。
- Next.js(App Router)
- TypeScript
- Prisma
- Zod
useActionState とは
React 19 で導入された useActionState は、サーバーアクション関数と初期状態を渡すだけで、
- フォーム送信結果に基づいて自動的に更新される
state - そのアクションをフォームに渡すための
formAction - 送信中かどうかを示す
isPending
の3つをまとめて扱える React フックです。
const [state, formAction, isPending] = useActionState(actionFn, initialState);
-
state:最後に返された送信結果(成功・エラー・入力値など) -
formAction:フォームのaction属性に渡す関数 -
isPending:送信中かどうかの真偽値(送信中はtrue) -
actionFn:フォームが送信されたときに呼び出される関数 -
initialState:フォームが初期化されたときの状態(stateの初期値)
Server Actions とは
Next.js の Server Actions は、関数冒頭に 'use server' を宣言した非同期関数として定義し、バリデーションやデータベース操作などのサーバー側処理をシリアライズ可能な引数・戻り値で実装できる仕組みです。
クライアントコンポーネントでは、フォームの action 属性にその関数を渡すだけで、自動的に POST リクエストとして呼び出せます。
定義方法
'use server'ディレクティブを付与した非同期関数として定義します。
この関数をエクスポートすると、自動的に Server Action として扱われます。
"use server";
export const submitForm = async (
previousState: ActionStateType,
formData: FormData
): Promise<ActionStateType> => {
// ここに入力チェックやDB操作などを書く
};
submitForm 関数を useActionState の第一引数に設定すると、前回の状態(previousState)と FormData を引数として受け取り、返り値として新しい状態をシリアライズ可能な形(JSON に変換できる構造)で返します。
呼び出し方法
クライアントコンポーネント内の<form action={formAction}>から呼び出せます。
従来のfetchや API ルートを経由する必要がないため、フォーム送信やデータ更新などのサーバー処理を Next.js アプリケーション内で完結させることができます。
Server Actions でお問い合わせフォームを作る
useActionStateと Server Actions を組み合わせて、お問い合わせフォームを作成します。
プロジェクトの構成は以下の通りです。
src/
├── app/
│ ├── complete/
│ │ └── page.tsx # 完了ページ
│ └── contact/
│ └── page.tsx # ContactFormを呼び出す
├── components/
│ └── ContactForm.tsx # お問い合わせフォーム
├── lib/
│ ├── actions/
│ │ └── submitForm.ts # Server Actions
│ └── prisma.ts # Prisma Client
└── validations/
└── contact.ts # Zod バリデーション
ContactForm テーブルの構成は以下のとおりです。
| カラム名 | データ型 | 制約 |
|---|---|---|
| id | String | 主キー、デフォルト値あり |
| userName | String | |
| String | 重複不可 | |
| message | String | |
| createdAt | DateTime | デフォルト値あり |
本記事では Prisma のインストールや使い方については触れませんので、Prisma が初めての方は他のリソースまたは以下の記事をご参照ください。
1. ContactForm コンポーネント
"use client";
import { ActionStateType, submitForm } from "@/lib/actions/submitForm";
import { useActionState } from "react";
const ContactForm = () => {
const [state, formAction, isPending] = useActionState<
ActionStateType,
FormData
>(submitForm, {
success: true,
userName: "",
mail: "",
});
return (
<form
className="flex items-center justify-center min-h-screen"
action={formAction}
>
<div className="w-auto h-auto flex flex-col gap-4 md:w-100">
<h3>お問い合わせフォーム</h3>
<div>
<input
type="text"
id="userName"
name="userName"
placeholder="ユーザー名"
defaultValue={state.userName}
className="input-base"
/>
{state.error?.userName && (
<p className="error-text">{state.error.userName}</p>
)}
</div>
<div>
<input
type="text"
id="mail"
name="mail"
placeholder="メールアドレス"
defaultValue={state.mail}
className="input-base"
/>
{state.error?.mail && (
<p className="error-text">{state.error.mail}</p>
)}
</div>
<textarea
name="message"
id="message"
placeholder="メッセージを入力してください。"
defaultValue={state.message}
className="h-40 input-base"
></textarea>
<button type="submit" disabled={isPending} className="submit-button">
{isPending ? "送信中..." : "送信"}
</button>
</div>
</form>
);
};
-
useActionState(submitForm, {...})でsubmitFormアクションと初期状態を紐付けします。 -
stateにはsubmitFormが返すActionStateTypeが常に最新で保持されます。 -
isPendingがtrueの間は、ボタンを非活性にし「送信中...」と表示します。 -
<form action={formAction}>により、ブラウザの標準フォーム送信で Server Action が呼び出されます。
2. Zod バリデーション
ZodはTypeScriptと相性の良いスキーマ宣言・バリデーションライブラリで、シンプルなコードで型安全なバリデーションが可能です。
以下のコードでは、userName と mail の2つのフィールドについて、必須チェックと形式・文字数制限を定義しています。
export const schema = z.object({
userName: z
.string()
.min(1, "氏名は必須です")
.max(20, "氏名は20文字以内で入力してください"),
mail: z
.string()
.min(1, "メールアドレスは必須です")
.email("メールアドレスを正しい形式で入力してください"),
});
スキーマ定義は以下の通りです。
-
.string():文字列ベースであること -
.min(1, "必須エラー"):1文字以上であること -
.max(20, "20文字以内で入力してください"):20文字以下であること -
.email("メールアドレスを正しい形式で入力してください"):メールアドレスの形式であること
3. submitForm サーバーアクション
"use server";
import { redirect } from "next/navigation";
import { schema } from "@/validations/contact";
import { prisma } from "../prisma";
export type ActionStateType = {
success: boolean;
userName: string;
mail: string;
message?: string;
error?: {
userName?: string[];
mail?: string[];
};
};
export const submitForm = async (
prev: ActionStateType,
formData: FormData
): Promise<ActionStateType> => {
const userName = formData.get("userName") as string;
const mail = formData.get("mail") as string;
const message = formData.get("message") as string;
// 1) 入力チェック
const validate = schema.safeParse({ userName, mail });
if (!validate.success) {
return {
success: false,
userName,
mail,
message,
error: {
userName: validate.error.flatten().fieldErrors.userName,
mail: validate.error.flatten().fieldErrors.mail,
},
};
}
// 2) メール重複チェック(Prisma Client)
const existRecord = await prisma.contactForm.findUnique({
where: { mail },
});
if (existRecord) {
return {
success: false,
userName,
mail,
message,
error: {
mail: ["このメールアドレスは既に使用されています"],
},
};
}
// 3) DB登録
await prisma.contactForm.create({
data: { userName, mail, message },
});
// 4) 完了ページへリダイレクト
redirect("/complete");
};
-
schema.safeParse({ userName, mail })で入力チェックを行い、success が false の時はerror.flatten().fieldErrorsでエラー一覧を取得できます。 -
prisma.contactForm.findUnique({ where: { mail } })で重複チェックします。nullであれば未登録、オブジェクトであれば登録済みとなります。 - 問題なければ
create()で新規登録します。 - 最後に
redirect("/complete")で完了ページへリダイレクトさせます。
まとめ
以上のように、useActionStateと Server Actions を組み合わせることで、複雑なfetchコードを書かずに、クライアント/サーバー間のフォーム送信ロジックを一貫して管理できます。
本記事が、 Next.js での効率的なフォーム処理の設計において、何か一つでもヒントになれば嬉しく思います。