メインコンテンツへスキップ
友田 陽大
Reactフォーム実装
React
React Hook Form
Next.js
Zod
TypeScript
フォーム
型安全
セキュリティ
フロントエンド

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保護までを本番品質の実コードで解説します。

公開日
最終更新日
最終更新
読了時間
11分
著者
友田 陽大
シェア

Next.js App Router(React 19)でフォームを作るとき、「React Hook Form を使うべきか、Server Actions + useActionState で十分か、両立できるのか」は最も多い悩みです。この記事は React useActionStateNext.js Server ActionsReact Hook Form の公式仕様を一次情報に、3つの現実的パターンと、その安全な実装を掘り下げます。検証スキーマは Zod 4 実践ガイド を前提とします。

検証した版(2026年6月時点): Next.js 16、React 19、react-hook-form v7 系、@hookform/resolvers v5 系、zod v4。


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

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

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


1. 大原則:クライアントは信用しない(信頼境界はサーバー)

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

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

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

// 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 として)

// 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. フォーム(クライアント)

"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 をフォームアクションとして使います。

// 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 };
}
"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 完全ガイドの該当節)。progressive: trueshouldUseNativeValidation: true でネイティブ検証属性も出力できます。
  2. native <form action> に RHF を重ねる: useFormregister しつつ <form action={formAction}> を併用し、クライアントでは onSubmithandleSubmit を通してから送信、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)実装ガイド、WAF・多層防御は WAF 多層防御ガイド を参照してください。


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

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

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

決済のように「絶対に二重実行してはならない」処理での冪等性設計は 二重課金を防ぐ冪等性ガイド に詳述しています。送信後に一覧を即時反映したい場合は useOptimistic で楽観的更新を(React 19 use/useOptimistic 実践ガイド)。


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

  • Server Action の単体テスト: 関数を直接呼び、unknown を渡して「不正入力で fieldErrors を返す/正常入力で ok を返す/未認可で弾く」を検証。検証の中心は Zod なので、ここが堅ければ安全性の大半が担保される。
  • フォームの結合テスト: @testing-library/reactuser-event でラベル経由に操作し、エラー表示・送信ボタンの無効化を検証(React Hook Form 完全ガイドのテスト節)。
  • E2E: 実ブラウザで送信フロー・no-JS フォールバック(B/C)を確認(Playwright E2E 実践ガイド)。
// 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 検証・認可・レート制限・冪等キーは必須。ここはパターンに依らない。

まとめ:パターンは選べる、原則は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 を、型安全・セキュリティ・信頼性を重視して設計・実装した過程を紹介しています。

よくある質問

Server Action を使うなら React Hook Form は不要では?
要件次第です。即時・きめ細かいバリデーション UX、動的フィールド、制御 UI ライブラリ統合が要るなら RHF(パターンA)。シンプルな公開フォームなら useActionState だけ(パターンB)で十分。両方の使い分けが正解です。
クライアントで Zod 検証しているのに、サーバーでもう一度検証するのは無駄では?
無駄ではなく必須です。クライアント検証は UX、サーバー検証はセキュリティ。スキーマを1つ共有すれば二重に書く手間もありません(DRY)。
useActionState と useFormState は同じ?
別物です。useActionState(React 19)は Server Action の状態管理。useFormStatus(react-dom)は送信中フラグ。RHF の useFormState は RHF のフォーム状態購読。名前が似ているだけで役割が違います。
サーバーが返したエラーをどう表示する?
パターン A は setError("field", ...) でフィールドへ、全体は root.server へ。パターン B は useActionState の state.fieldErrors を読む。いずれも role="alert" で支援技術に通知します。
CSRF は大丈夫?
Next.js の Server Actions は同一オリジン・POST 前提で設計され、フレームワークが基本的な保護を提供します。とはいえ認可は自前で必須——「叩けること」と「叩いてよいこと」は別です。
友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

お困りごとはありませんか?

設計から実装・運用まで、一人 × 生成AI で伴走します

この記事のような実装を、要件定義から本番運用まで一気通貫で。まずは30分の無料技術相談から、状況をお聞かせください。

プロジェクト単位(請負)・技術顧問のどちらにも対応可能です。まずは30分の無料技術相談から。

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

あわせて読みたい