useActionState × Server Actions でつくるフォーム処理（fetch不要）

## はじめに

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`  

の３つをまとめて扱える React フックです。  

```ts
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 として扱われます。

```ts
"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 を組み合わせて、お問い合わせフォームを作成します。
プロジェクトの構成は以下の通りです。

```bash
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   |                          |
| mail      | String   | 重複不可                 |
| message   | String   |                          |
| createdAt | DateTime | デフォルト値あり         |

本記事では Prisma のインストールや使い方については触れませんので、Prisma が初めての方は他のリソースまたは以下の記事をご参照ください。

https://qiita.com/m-bitterC2/items/037241587eadccf1ba80

### 1. `ContactForm` コンポーネント

```tsx: @/components/ContactForm.tsx
"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つのフィールドについて、必須チェックと形式・文字数制限を定義しています。

```ts: @/validations/contact.ts
export const schema = z.object({
  userName: z
    .string()
    .min(1, "氏名は必須です")
    .max(20, "氏名は20文字以内で入力してください"),
  mail: z
    .string()
    .min(1, "メールアドレスは必須です")
    .email("メールアドレスを正しい形式で入力してください"),
});
```

スキーマ定義は以下の通りです。

- `.string()`：文字列ベースであること
- `.min(1, "必須エラー")`：１文字以上であること
- `.max(20, "20文字以内で入力してください")`：20文字以下であること
- `.email("メールアドレスを正しい形式で入力してください")`：メールアドレスの形式であること

### 3. `submitForm` サーバーアクション

```ts
"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");
};
```

1. `schema.safeParse({ userName, mail })`で入力チェックを行い、success が false の時は`error.flatten().fieldErrors`でエラー一覧を取得できます。
2. `prisma.contactForm.findUnique({ where: { mail } })`で重複チェックします。`null`であれば未登録、オブジェクトであれば登録済みとなります。
3. 問題なければ`create()`で新規登録します。
4. 最後に`redirect("/complete")`で完了ページへリダイレクトさせます。

---

## まとめ

以上のように、`useActionState`と Server Actions を組み合わせることで、複雑な`fetch`コードを書かずに、クライアント／サーバー間のフォーム送信ロジックを一貫して管理できます。

本記事が、 Next.js での効率的なフォーム処理の設計において、何か一つでもヒントになれば嬉しく思います。