Next.js App Router(React 19)でフォームを作るとき、「React Hook Form を使うべきか、Server Actions + useActionState で十分か、両立できるのか」は最も多い悩みです。この記事は React useActionState と Next.js Server Actions、React Hook Form の公式仕様を一次情報に、3つの現実的パターンと、その安全な実装を掘り下げます。検証スキーマは Zod 4 実践ガイド を前提とします。
検証した版(2026年6月時点): Next.js 16、React 19、
react-hook-formv7 系、@hookform/resolversv5 系、zodv4。
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" とも独立した純粋なモジュールに置きます。
// 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方向あります。
- RHF の
<Form>コンポーネントを使う:actionを指定すると検証後にfetchで POST、省略すればネイティブ送信になります(React Hook Form 完全ガイドの該当節)。progressive: true+shouldUseNativeValidation: trueでネイティブ検証属性も出力できます。 - 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)実装ガイド、WAF・多層防御は WAF 多層防御ガイド を参照してください。
6. 冪等性と回復性:リトライを「正常系」にする
ネットワークは切れ、ユーザーは連打し、リクエストは重複します。これを前提に設計します。
- クライアント: 送信中は
disabled={isSubmitting}(A)/disabled={pending}(B)でボタンを止める。 - ペイロード: 送信ごとに一意の
idempotencyKey(crypto.randomUUID())を載せる。 - サーバー: 同じキーの処理は1回だけ実行(
insertIfAbsent相当)。2回目以降は成功として返す——これでリトライが二重課金や二重登録にならない。
決済のように「絶対に二重実行してはならない」処理での冪等性設計は 二重課金を防ぐ冪等性ガイド に詳述しています。送信後に一覧を即時反映したい場合は useOptimistic で楽観的更新を(React 19 use/useOptimistic 実践ガイド)。
7. テスト:検証ロジックとフォームを別々に
- Server Action の単体テスト: 関数を直接呼び、
unknownを渡して「不正入力でfieldErrorsを返す/正常入力でokを返す/未認可で弾く」を検証。検証の中心は Zod なので、ここが堅ければ安全性の大半が担保される。 - フォームの結合テスト:
@testing-library/react+user-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. 決定フロー(迷ったらこれ)
- JS 無効でも送信できる必要があるか? → No なら **A(client-first)**で決まり。
- Yes かつ 入力中の即時検証 UX も妥協できないか? → Yes なら C(hybrid)、妥協できるなら B(progressive-first)。
- いずれでも サーバー側 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つの大原則を全パターンで守るだけです——
- A / B / C を「JS 必須か × 即時検証 UX が要るか」で選ぶ。 多くは A か B で足りる。
- 信頼境界はサーバー。 同じ Zod を両端で。サーバー検証が真実。
- Server Action は公開エンドポイント。 認可・検証・レート制限を中で必ず行う。
- 二重送信は UI(disabled)+ 冪等キーで構造的に防ぐ。
- 検証ロジックとフォームを別々にテストし、信頼境界そのものを守る。
これは「動くフォーム」ではなく「本番で壊れない・悪用されない・速いフォーム」を作るための設計です。問い合わせや申込はビジネスの入口——ここの信頼性は売上に直結します。
Next.js での安全なフォーム基盤の設計・Server Actions のセキュリティレビュー・既存フォームの本番品質化が必要な場合は、お気軽にご相談ください。 下記の事例では、業界の基幹業務を支える B2B SaaS を、型安全・セキュリティ・信頼性を重視して設計・実装した過程を紹介しています。