# React Hook Form × Next.js Server Actions 実践ガイド【2026年最新】— useActionState・二重検証・プログレッシブ・エンハンスメント

> Next.js App Router（React 19）で React Hook Form と Server Actions を安全に組み合わせる実践ガイド。client-first（RHF主役）/ progressive-first（useActionState）/ hybrid の3パターンを比較し、1つの Zod スキーマでクライアント／サーバー二重検証、サーバーエラーの setError 反映、二重送信防止と冪等性、認可・レート制限・PII保護までを本番品質の実コードで解説します。

- 公開日: 2026-06-26
- 著者: 友田 陽大
- タグ: React, React Hook Form, Next.js, Zod, TypeScript, フォーム, 型安全, セキュリティ, フロントエンド
- URL: https://tomodahinata.com/blog/react-hook-form-nextjs-server-actions-useactionstate-guide

## 要点

- 信頼境界はサーバー。クライアント検証は UX、サーバー検証が真実——同じ Zod スキーマを両端で二重に適用する
- client-first（RHF主役・onValidでServer Action呼び出し）/ progressive-first（useActionState・JSなしでも動く）/ hybrid を要件で選ぶ
- Server Action は公開HTTPエンドポイント。中で必ず認可・Zod検証・レート制限を行い、PIIはログに残さない
- サーバーが返す項目別エラーは setError でフィールドへ、全体エラーは root.server へ。isSubmitting で二重送信を封じる
- 冪等キーをペイロードに載せ、サーバーで重複実行を弾く——リトライを正常系にする

---

Next.js App Router（React 19）でフォームを作るとき、「React Hook Form を使うべきか、Server Actions ＋ `useActionState` で十分か、両立できるのか」は最も多い悩みです。この記事は [React `useActionState`](https://react.dev/reference/react/useActionState) と [Next.js Server Actions](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations)、[React Hook Form](/blog/react-hook-form) の公式仕様を一次情報に、**3つの現実的パターンと、その安全な実装**を掘り下げます。検証スキーマは [Zod 4 実践ガイド](/blog/zod) を前提とします。

> **検証した版（2026年6月時点）：** Next.js 16、React 19、`react-hook-form` v7 系、`@hookform/resolvers` v5 系、`zod` v4。

---

## 0. 結論：3つの選択肢を要件で選ぶ

| パターン | 主役 | JSなしで動く | 即時バリデーションUX | 向いている場面 |
| --- | --- | --- | --- | --- |
| A. client-first | React Hook Form | いいえ | 強い | 管理画面・ログイン後の業務フォーム。リッチなUXが要る |
| B. progressive-first | `useActionState` | はい | 弱い | 公開フォーム・問い合わせ・到達性最優先 |
| C. hybrid | RHF ＋ native action | はい | 強い | 公開かつリッチ。実装コストは最も高い |

**選び方の軸は2つ**——(1) JS 無効環境でも送信できる必要があるか、(2) 入力中のきめ細かい検証 UX が要るか。どちらか一方なら A か B で十分。両方なら C を検討します。ただし、**どのパターンでも共通する大原則が1つ**あります。

---

## 1. 大原則：クライアントは信用しない（信頼境界はサーバー）

クライアント側の検証は**UX のため**であって、セキュリティではありません。`zodResolver` をすり抜けて改ざんされたリクエストは普通に届きます。だから——

> **同じ Zod スキーマを、クライアントとサーバーの両方で適用する。サーバー側の検証が「真実」。**

スキーマを1つにすれば、型・ルール・文言が一元化され（DRY）、二重検証も自然に書けます。スキーマは `"use client"` とも `"use server"` とも独立した純粋なモジュールに置きます。

```ts
// lib/schemas/contact.ts —— クライアント／サーバー共有の単一の正
import * as z from "zod";

export const contactSchema = z.object({
  email: z.email({ error: "メールアドレスを正しく入力してください" }),
  message: z.string().min(10, { error: "10文字以上で入力してください" }).max(2000),
  // 二重送信対策の冪等キー（クライアント生成・UUID）
  idempotencyKey: z.uuid(),
});
export type Contact = z.infer<typeof contactSchema>;
```

---

## 2. パターンA：client-first（RHF が主役）

最もリッチな UX。RHF がフォームを所有し、`handleSubmit` の中で **Server Action を普通の async 関数として呼び出し**、返ってきた結果をフォームに反映します。

### 2-1. Server Action（RPC として）

```ts
// app/contact/actions.ts
"use server";
import * as z from "zod";
import { contactSchema, type Contact } from "@/lib/schemas/contact";
import { auth } from "@/lib/auth";
import { rateLimit } from "@/lib/rate-limit";

// 返り値は判別可能ユニオン。クライアントはこれを型として受け取れる
type SubmitResult =
  | { ok: true }
  | { ok: false; formError?: string; fieldErrors?: Partial<Record<keyof Contact, string>> };

export async function submitContact(input: unknown): Promise<SubmitResult> {
  // 1) 認可：誰がこの操作をしてよいか（Server Action は公開エンドポイント）
  const session = await auth();
  if (!session) return { ok: false, formError: "ログインが必要です" };

  // 2) レート制限：濫用・スパムを止める
  const allowed = await rateLimit(`contact:${session.userId}`);
  if (!allowed) return { ok: false, formError: "時間をおいて再度お試しください" };

  // 3) 検証：信頼境界。input は unknown として必ず parse する
  const parsed = contactSchema.safeParse(input);
  if (!parsed.success) {
    const { fieldErrors } = z.flattenError(parsed.error);
    return {
      ok: false,
      // 各フィールドの先頭メッセージだけ返す
      fieldErrors: Object.fromEntries(
        Object.entries(fieldErrors).map(([k, v]) => [k, v?.[0]]),
      ) as SubmitResult["fieldErrors"],
    };
  }

  // 4) 冪等性：同じ idempotencyKey の重複実行を弾く
  const created = await db.contacts.insertIfAbsent(parsed.data); // key 重複なら no-op
  if (!created) return { ok: true }; // 既に処理済み＝成功扱い（リトライを正常系に）

  return { ok: true };
}
```

### 2-2. フォーム（クライアント）

```tsx
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { contactSchema, type Contact } from "@/lib/schemas/contact";
import { submitContact } from "./actions";

export function ContactForm() {
  const {
    register,
    handleSubmit,
    setError,
    reset,
    formState: { errors, isSubmitting, isSubmitSuccessful },
  } = useForm<Contact>({
    resolver: zodResolver(contactSchema),
    defaultValues: { email: "", message: "", idempotencyKey: crypto.randomUUID() },
  });

  const onValid = async (data: Contact) => {
    const res = await submitContact(data); // Server Action を RPC として呼ぶ
    if (res.ok) {
      reset({ email: "", message: "", idempotencyKey: crypto.randomUUID() }); // 次回用に新キー
      return;
    }
    // サーバーの項目別エラーを該当フィールドへ
    for (const [name, message] of Object.entries(res.fieldErrors ?? {})) {
      if (message) setError(name as keyof Contact, { message });
    }
    // 全体エラーは root.server へ
    if (res.formError) setError("root.server", { message: res.formError });
  };

  return (
    <form onSubmit={handleSubmit(onValid)} noValidate className="space-y-4">
      <div>
        <label htmlFor="email">メールアドレス</label>
        <input
          id="email"
          type="email"
          {...register("email")}
          aria-invalid={errors.email ? "true" : "false"}
          aria-describedby={errors.email ? "email-error" : undefined}
        />
        {errors.email && <p id="email-error" role="alert">{errors.email.message}</p>}
      </div>

      <div>
        <label htmlFor="message">お問い合わせ</label>
        <textarea
          id="message"
          {...register("message")}
          aria-invalid={errors.message ? "true" : "false"}
          aria-describedby={errors.message ? "message-error" : undefined}
        />
        {errors.message && <p id="message-error" role="alert">{errors.message.message}</p>}
      </div>

      {errors.root?.server && <p role="alert">{errors.root.server.message}</p>}
      {isSubmitSuccessful && <p role="status">送信しました。ありがとうございます。</p>}

      {/* 送信中は無効化＝二重送信を構造的に防ぐ */}
      <button disabled={isSubmitting}>{isSubmitting ? "送信中…" : "送信"}</button>
    </form>
  );
}
```

**この構成の強み：** 即時バリデーション・項目別サーバーエラー・二重送信防止・型安全（`submitContact` の戻り値が判別可能ユニオン）。欠点は **JS 必須**であること。ログイン後の管理画面・業務フォームではこれが最適です。

---

## 3. パターンB：progressive-first（`useActionState`）

JS が読み込まれる前でも送信できる**プログレッシブ・エンハンスメント**重視。`useActionState` ＋ native `<form action>` で、Server Action を**フォームアクション**として使います。

```ts
// app/contact/actions.ts（progressive 版）
"use server";
import * as z from "zod";
import { contactSchema } from "@/lib/schemas/contact";

export type FormState = {
  ok: boolean;
  formError?: string;
  fieldErrors?: Record<string, string[] | undefined>;
};

export async function contactAction(_prev: FormState, formData: FormData): Promise<FormState> {
  // FormData → オブジェクト化して Zod で検証（サーバーが唯一の検証者）
  const parsed = contactSchema.safeParse({
    email: formData.get("email"),
    message: formData.get("message"),
    idempotencyKey: formData.get("idempotencyKey"),
  });
  if (!parsed.success) {
    return { ok: false, fieldErrors: z.flattenError(parsed.error).fieldErrors };
  }
  await db.contacts.insertIfAbsent(parsed.data);
  return { ok: true };
}
```

```tsx
"use client";
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
import { contactAction, type FormState } from "./actions";

const initial: FormState = { ok: false };

function SubmitButton() {
  const { pending } = useFormStatus(); // form 内の子で pending を取る
  return <button disabled={pending}>{pending ? "送信中…" : "送信"}</button>;
}

export function ContactForm() {
  const [state, formAction] = useActionState(contactAction, initial);
  return (
    <form action={formAction} noValidate className="space-y-4">
      <label htmlFor="email">メールアドレス</label>
      <input id="email" name="email" type="email" required aria-invalid={!!state.fieldErrors?.email} />
      {state.fieldErrors?.email && <p role="alert">{state.fieldErrors.email[0]}</p>}

      <label htmlFor="message">お問い合わせ</label>
      <textarea id="message" name="message" required minLength={10} />
      {state.fieldErrors?.message && <p role="alert">{state.fieldErrors.message[0]}</p>}

      <input type="hidden" name="idempotencyKey" value={crypto.randomUUID()} />
      {state.ok && <p role="status">送信しました。</p>}
      <SubmitButton />
    </form>
  );
}
```

**強み：** JS 無効でも `<form action>` がネイティブ送信され、サーバーで検証・処理される。`required`/`minLength` などネイティブ属性で最低限のクライアント検証も効く。**欠点：** 入力中のきめ細かい即時検証（タッチした瞬間の指摘など）は弱い。問い合わせ・公開フォームに最適です。

---

## 4. パターンC：hybrid（RHF の即時検証 ＋ no-JS フォールバック）

「公開フォームだが、JS 環境ではリッチに検証したい」場合の合わせ技です。現実的な実装は2方向あります。

1. **RHF の `<Form>` コンポーネントを使う：** `action` を指定すると検証後に `fetch` で POST、省略すればネイティブ送信になります（[React Hook Form 完全ガイドの該当節](/blog/react-hook-form)）。`progressive: true` ＋ `shouldUseNativeValidation: true` でネイティブ検証属性も出力できます。
2. **native `<form action>` に RHF を重ねる：** `useForm` で `register` しつつ `<form action={formAction}>` を併用し、クライアントでは `onSubmit` で `handleSubmit` を通してから送信、JS 無効時はネイティブ送信にフォールバック。

> **正直なトレードオフ：** hybrid は「2つの送信経路（fetch とネイティブ）」と「2つの検証経路」を抱えるため、状態同期とエラー表示の一貫性を保つのが最も難しい。**多くのプロジェクトは A か B のどちらかで十分**です。到達性が最優先なら B、UX が最優先で JS を前提にできるなら A。「両方必須」と確信できるときだけ C を選んでください（YAGNI）。

---

## 5. セキュリティ：Server Action は「公開エンドポイント」

最重要の認識——**Server Action はビルド後、誰でも叩ける HTTP エンドポイントになります**。`"use server"` の中で必ず次を行います。

- **認可（Authorization）：** 「ログイン済みか」だけでなく「この操作をこのユーザーがしてよいか」を毎回確認する。UI で隠しても、エンドポイントは生きている。
- **入力検証：** 引数は常に `unknown` として Zod で `safeParse`。TypeScript の型は**コンパイル時の約束**で、実行時の防御にはならない。
- **レート制限：** スパム・総当たり対策。ユーザー／IP 単位で絞る（この portfolio では `lib/rate-limit`）。
- **PII を残さない：** メールアドレスや本文をそのままログに出さない。相関 ID とフェーズタグだけ記録する。
- **秘密情報：** Server Action のクロージャは暗号化されるが、それに依存して秘密をクライアント由来の値で扱わない。秘密は環境変数／シークレットマネージャから。

認可・認証の設計は [認証・認可（OIDC/JWT）実装ガイド](/blog/id-token-vs-access-token-oidc-oauth2-guide)、WAF・多層防御は [WAF 多層防御ガイド](/blog/waf-defense-in-depth-aws-waf-cloud-armor-owasp-guide) を参照してください。

---

## 6. 冪等性と回復性：リトライを「正常系」にする

ネットワークは切れ、ユーザーは連打し、リクエストは重複します。これを前提に設計します。

- **クライアント：** 送信中は `disabled={isSubmitting}`（A）／`disabled={pending}`（B）でボタンを止める。
- **ペイロード：** 送信ごとに一意の `idempotencyKey`（`crypto.randomUUID()`）を載せる。
- **サーバー：** 同じキーの処理は1回だけ実行（`insertIfAbsent` 相当）。2回目以降は**成功として返す**——これでリトライが二重課金や二重登録にならない。

決済のように「絶対に二重実行してはならない」処理での冪等性設計は [二重課金を防ぐ冪等性ガイド](/blog/payment-double-charge-prevention-idempotency-procurement-guide) に詳述しています。送信後に一覧を即時反映したい場合は `useOptimistic` で楽観的更新を（[React 19 use/useOptimistic 実践ガイド](/blog/react-19-use-useoptimistic-hooks-practical-guide)）。

---

## 7. テスト：検証ロジックとフォームを別々に

- **Server Action の単体テスト：** 関数を直接呼び、`unknown` を渡して「不正入力で `fieldErrors` を返す／正常入力で `ok` を返す／未認可で弾く」を検証。検証の中心は Zod なので、ここが堅ければ安全性の大半が担保される。
- **フォームの結合テスト：** `@testing-library/react` ＋ `user-event` でラベル経由に操作し、エラー表示・送信ボタンの無効化を検証（[React Hook Form 完全ガイドのテスト節](/blog/react-hook-form)）。
- **E2E：** 実ブラウザで送信フロー・no-JS フォールバック（B/C）を確認（[Playwright E2E 実践ガイド](/blog/playwright-e2e-testing-production-design-guide)）。

```ts
// actions.test.ts —— 信頼境界そのものをテストする
import { describe, it, expect } from "vitest";
import { submitContact } from "./actions";

it("不正な入力は fieldErrors を返し DB に触れない", async () => {
  const res = await submitContact({ email: "bad", message: "短い", idempotencyKey: "x" });
  expect(res.ok).toBe(false);
  if (!res.ok) expect(res.fieldErrors?.email).toBeTruthy();
});
```

---

## 8. 決定フロー（迷ったらこれ）

1. **JS 無効でも送信できる必要があるか？** → No なら **A（client-first）**で決まり。
2. Yes かつ **入力中の即時検証 UX も妥協できないか？** → Yes なら **C（hybrid）**、妥協できるなら **B（progressive-first）**。
3. いずれでも **サーバー側 Zod 検証・認可・レート制限・冪等キーは必須**。ここはパターンに依らない。

---

## 9. FAQ

**Q. Server Action を使うなら React Hook Form は不要では？**
A. 要件次第です。即時・きめ細かいバリデーション UX、動的フィールド、制御 UI ライブラリ統合が要るなら RHF（A）。シンプルな公開フォームなら `useActionState` だけ（B）で十分。両方の使い分けが正解です。

**Q. クライアントで Zod 検証しているのに、サーバーでもう一度検証するのは無駄では？**
A. 無駄ではなく**必須**です。クライアント検証は UX、サーバー検証はセキュリティ。スキーマを1つ共有すれば二重に書く手間もありません（DRY）。

**Q. `useActionState` と `useFormState` は同じ？**
A. 別物です。`useActionState`（React 19）は Server Action の状態管理。`useFormStatus`（react-dom）は送信中フラグ。RHF の `useFormState` は RHF のフォーム状態購読。名前が似ているだけで役割が違います。

**Q. サーバーが返したエラーをどう表示する？**
A. パターン A は `setError("field", ...)` でフィールドへ、全体は `root.server` へ。パターン B は `useActionState` の `state.fieldErrors` を読む。いずれも `role="alert"` で支援技術に通知します。

**Q. CSRF は大丈夫？**
A. Next.js の Server Actions は同一オリジン・POST 前提で設計され、フレームワークが基本的な保護を提供します。とはいえ**認可は自前で必須**——「叩けること」と「叩いてよいこと」は別です。

---

## まとめ：パターンは選べる、原則は1つ

Next.js での RHF × Server Actions は、見かけほど複雑ではありません。**3つのパターンから要件で選び、1つの大原則を全パターンで守る**だけです——

1. **A / B / C を「JS 必須か × 即時検証 UX が要るか」で選ぶ。** 多くは A か B で足りる。
2. **信頼境界はサーバー。** 同じ Zod を両端で。サーバー検証が真実。
3. **Server Action は公開エンドポイント。** 認可・検証・レート制限を中で必ず行う。
4. **二重送信は UI（disabled）＋ 冪等キー**で構造的に防ぐ。
5. **検証ロジックとフォームを別々にテスト**し、信頼境界そのものを守る。

これは「動くフォーム」ではなく「**本番で壊れない・悪用されない・速いフォーム**」を作るための設計です。問い合わせや申込はビジネスの入口——ここの信頼性は売上に直結します。

**Next.js での安全なフォーム基盤の設計・Server Actions のセキュリティレビュー・既存フォームの本番品質化が必要な場合は、お気軽にご相談ください。** 下記の事例では、業界の基幹業務を支える B2B SaaS を、型安全・セキュリティ・信頼性を重視して設計・実装した過程を紹介しています。
