# React Hook Form × Next.js Server Actions practical guide [latest 2026] — useActionState, double validation, progressive enhancement

> A practical guide to safely combining React Hook Form and Server Actions in the Next.js App Router (React 19). It compares the three patterns client-first (RHF leading) / progressive-first (useActionState) / hybrid, and explains in production-quality real code client/server double validation with one Zod schema, reflecting server errors via setError, double-submit prevention and idempotency, and authorization, rate limiting, and PII protection.

- Published: 2026-06-26
- Author: 友田 陽大
- Tags: React, React Hook Form, Next.js, Zod, TypeScript, フォーム, 型安全, セキュリティ, フロントエンド
- URL: https://tomodahinata.com/en/blog/react-hook-form-nextjs-server-actions-useactionstate-guide
- Category: React forms
- Pillar guide: https://tomodahinata.com/en/blog/react-hook-form

## Key points

- The trust boundary is the server. Client validation is UX, server validation is the truth — apply the same Zod schema doubly at both ends.
- Choose by requirements: client-first (RHF leading, call the Server Action on onValid) / progressive-first (useActionState, works even without JS) / hybrid.
- A Server Action is a public HTTP endpoint. Always perform authorization, Zod validation, and rate limiting inside it, and don't leave PII in logs.
- Put the server's per-field errors onto the field with setError, and the overall error onto root.server. Seal double submission with isSubmitting.
- Put an idempotency key on the payload and reject duplicate execution on the server — make retries the normal path.

---

When building a form in the Next.js App Router (React 19), "should I use React Hook Form, is Server Actions + `useActionState` enough, or can they coexist" is the most common worry. This article digs into **three realistic patterns and their safe implementation,** with the official specs of [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), and [React Hook Form](/blog/react-hook-form) as primary sources. The validation schema presupposes the [Zod 4 practical guide](/blog/zod).

> **The versions verified (as of June 2026):** Next.js 16, React 19, `react-hook-form` v7 family, `@hookform/resolvers` v5 family, `zod` v4.

---

## 0. Conclusion: choose among three options by requirements

| Pattern | Lead | Works without JS | Instant-validation UX | Suited scene |
| --- | --- | --- | --- | --- |
| A. client-first | React Hook Form | No | Strong | Admin panels, post-login business forms. Rich UX needed |
| B. progressive-first | `useActionState` | Yes | Weak | Public forms, inquiries, reach-first |
| C. hybrid | RHF + native action | Yes | Strong | Public and rich. The implementation cost is the highest |

**The axes for choosing are two** — (1) do you need to be able to submit even in a JS-disabled environment, and (2) do you need fine-grained validation UX during input. If only one, A or B suffices. If both, consider C. However, **there's one grand principle common to every pattern.**

---

## 1. The grand principle: don't trust the client (the trust boundary is the server)

Client-side validation is **for UX,** not security. A request that slips past `zodResolver` and is tampered with arrives just fine. So —

> **Apply the same Zod schema on both the client and the server. The server-side validation is "the truth."**

If you make the schema one, the types, rules, and wording are centralized (DRY), and double validation is also written naturally. Place the schema in a pure module independent of both `"use client"` and `"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. Pattern A: client-first (RHF leading)

The richest UX. RHF owns the form, and inside `handleSubmit` it **calls the Server Action as an ordinary async function** and reflects the returned result onto the form.

### 2-1. The Server Action (as 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. The form (client)

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

**The strengths of this composition:** instant validation, per-field server errors, double-submit prevention, type safety (`submitContact`'s return value is a discriminated union). The downside is **JS being required.** For post-login admin panels and business forms, this is optimal.

---

## 3. Pattern B: progressive-first (`useActionState`)

Emphasizes **progressive enhancement** so it can submit even before JS loads. With `useActionState` + native `<form action>`, you use the Server Action as a **form 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>
  );
}
```

**Strengths:** even with JS disabled, `<form action>` submits natively and is validated/processed on the server. Native attributes like `required`/`minLength` also provide minimal client validation. **Downside:** fine-grained instant validation during input (like pointing it out the moment you touch a field) is weak. Optimal for inquiries and public forms.

---

## 4. Pattern C: hybrid (RHF's instant validation + no-JS fallback)

A combined technique for when "it's a public form, but in a JS environment I want to validate richly." There are two realistic implementations.

1. **Use RHF's `<Form>` component:** if you specify `action`, after validation it POSTs via `fetch`; if you omit it, it becomes native submission ([the relevant section of the React Hook Form complete guide](/blog/react-hook-form)). With `progressive: true` + `shouldUseNativeValidation: true`, you can also output native validation attributes.
2. **Layer RHF onto a native `<form action>`:** while you `register` with `useForm`, also use `<form action={formAction}>`, and on the client, on `onSubmit`, go through `handleSubmit` before submitting, falling back to native submission when JS is disabled.

> **The honest trade-off:** hybrid holds "two submission routes (fetch and native)" and "two validation routes," so keeping state synchronization and error-display consistency is the hardest. **For many projects, either A or B is enough.** If reach is the top priority, B; if UX is the top priority and you can presuppose JS, A. Choose C only when you're convinced "both are mandatory" (YAGNI).

---

## 5. Security: a Server Action is a "public endpoint"

The most important recognition — **after build, a Server Action becomes an HTTP endpoint anyone can hit.** Inside `"use server"`, always do the following.

- **Authorization:** confirm every time not only "is the user logged in" but "may this user perform this operation." Even if you hide it in the UI, the endpoint is alive.
- **Input validation:** always `safeParse` the arguments with Zod as `unknown`. TypeScript's types are a **compile-time promise** and aren't a runtime defense.
- **Rate limiting:** anti-spam, anti-brute-force. Narrow per user/IP (in this portfolio, `lib/rate-limit`).
- **Don't leave PII:** don't log the email address or body as-is. Record only the correlation ID and phase tag.
- **Secrets:** the Server Action's closure is encrypted, but don't rely on that to handle secrets with client-derived values. Secrets come from environment variables / a secrets manager.

For authorization/authentication design, see the [authentication/authorization (OIDC/JWT) implementation guide](/blog/id-token-vs-access-token-oidc-oauth2-guide), and for WAF / defense-in-depth, the [WAF defense-in-depth guide](/blog/waf-defense-in-depth-aws-waf-cloud-armor-owasp-guide).

---

## 6. Idempotency and resilience: make retries the "normal path"

The network drops, the user mashes the button, and requests duplicate. Design on this premise.

- **Client:** during submission, stop the button with `disabled={isSubmitting}` (A) / `disabled={pending}` (B).
- **Payload:** put a unique `idempotencyKey` (`crypto.randomUUID()`) on each submission.
- **Server:** execute the processing for the same key only once (equivalent to `insertIfAbsent`). From the second time on, **return as success** — this keeps a retry from becoming a double charge or double registration.

The idempotency design for processing where "it must never be executed twice," like payments, is detailed in the [idempotency guide for preventing double charges](/blog/payment-double-charge-prevention-idempotency-procurement-guide). If you want to reflect a list immediately after submission, use `useOptimistic` for optimistic updates ([React 19 use/useOptimistic practical guide](/blog/react-19-use-useoptimistic-hooks-practical-guide)).

---

## 7. Testing: test the validation logic and the form separately

- **Unit test of the Server Action:** call the function directly, pass `unknown`, and verify "it returns `fieldErrors` on invalid input / returns `ok` on valid input / rejects the unauthorized." Since the center of validation is Zod, if this is solid, most of the safety is guaranteed.
- **Integration test of the form:** with `@testing-library/react` + `user-event`, operate via labels and verify error display and the disabling of the submit button ([the testing section of the React Hook Form complete guide](/blog/react-hook-form)).
- **E2E:** confirm the submission flow and the no-JS fallback (B/C) in a real browser ([Playwright E2E practical guide](/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. Decision flow (when in doubt, this)

1. **Do you need to be able to submit even with JS disabled?** → If No, it's settled as **A (client-first).**
2. Yes, and **can you not compromise on instant-validation UX during input either?** → If Yes, **C (hybrid)**; if you can compromise, **B (progressive-first).**
3. In any case, **server-side Zod validation, authorization, rate limiting, and an idempotency key are mandatory.** This doesn't depend on the pattern.

---

## 9. FAQ

**Q. If I use Server Actions, isn't React Hook Form unnecessary?**
A. It depends on requirements. If you need instant, fine-grained validation UX, dynamic fields, or controlled-UI-library integration, RHF (A). For a simple public form, `useActionState` alone (B) suffices. Using both depending on the case is the correct answer.

**Q. Isn't validating again on the server, when I'm already doing Zod validation on the client, wasteful?**
A. It's not wasteful but **mandatory.** Client validation is UX, server validation is security. If you share one schema, there's no effort of writing it twice (DRY).

**Q. Are `useActionState` and `useFormState` the same?**
A. They're different things. `useActionState` (React 19) is state management for a Server Action. `useFormStatus` (react-dom) is the submitting flag. RHF's `useFormState` is RHF's form-state subscription. They just have similar names but different roles.

**Q. How do I display the errors the server returned?**
A. In pattern A, with `setError("field", ...)` onto the field, and the overall onto `root.server`. In pattern B, read `useActionState`'s `state.fieldErrors`. In either, notify assistive technology with `role="alert"`.

**Q. Is CSRF okay?**
A. Next.js's Server Actions are designed on the premise of same-origin and POST, and the framework provides basic protection. That said, **authorization is mandatory on your own** — "being able to hit it" and "being allowed to hit it" are different.

---

## Summary: you can choose the pattern, the principle is one

RHF × Server Actions in Next.js isn't as complex as it looks. You just **choose among three patterns by requirements and protect one grand principle in every pattern** —

1. **Choose A / B / C by "is JS required × is instant-validation UX needed."** Many cases are fine with A or B.
2. **The trust boundary is the server.** The same Zod at both ends. Server validation is the truth.
3. **A Server Action is a public endpoint.** Always do authorization, validation, and rate limiting inside it.
4. **Prevent double submission structurally with the UI (disabled) + an idempotency key.**
5. **Test the validation logic and the form separately,** and protect the trust boundary itself.

This is design for building not "a working form" but "**a form that doesn't break in production, isn't abused, and is fast.**" Inquiries and applications are the entrance to the business — the reliability here is directly tied to revenue.

**If you need to design a safe form foundation in Next.js, a security review of Server Actions, or to bring an existing form up to production quality, please feel free to consult me.** The case study below introduces the process of designing and implementing a B2B SaaS that supports an industry's core operations, with an emphasis on type safety, security, and reliability.
