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 検証・認可・レート制限・冪等キーは必須。ここはパターンに依らない。
まとめ:パターンは選べる、原則は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 を、型安全・セキュリティ・信頼性を重視して設計・実装した過程を紹介しています。