はじめに
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 での効率的なフォーム処理の設計において、何か一つでもヒントになれば嬉しく思います。