# The Complete React Hook Form Guide [2026 Latest, v7.80] — Type-Safe Forms, Re-Render Design, a11y, Dynamic Fields, Server Actions, Testing

> Using the latest official docs (React Hook Form v7.80 / @hookform/resolvers v5 / Zod 4) as the primary source, type-safe form design explained at production quality. zodResolver integration, formState's Proxy subscription, re-render design with watch / useWatch / useFormState, FormProvider and type-safe reusable fields, useFieldArray's dynamic line items, async defaultValues, a11y, double validation with Server Actions, and testing — so you can judge 'when and how to use' with real code.

- Published: 2026-06-25
- Author: 友田 陽大
- Tags: React, React Hook Form, Zod, TypeScript, Next.js, フォーム, 型安全, パフォーマンス, フロントエンド
- URL: https://tomodahinata.com/en/blog/react-hook-form
- Category: React forms

## Key points

- RHF is uncontrolled-component-first, minimizing re-renders; even forms with dozens of fields hardly re-render while typing (latest is the v7.80 line)
- Delegate validation to Zod 4 via zodResolver, flowing type, validation, and error messages from a single schema as the single source of truth (DRY)
- formState is a Proxy, so isValid etc. won't update unless you destructure and subscribe to them BEFORE the conditional — the biggest pitfall
- Re-rendering is a design target: choose useWatch / useFormState / getValues over watch, and isolate subscriptions per component
- Make type-safe reusable fields with FormProvider × useController, and finish at production quality with the a11y trio and client/server double validation

---

This article uses the latest versions of the React Hook Form (hereafter RHF) official docs — [Get Started](https://react-hook-form.com/get-started), [useForm](https://react-hook-form.com/docs/useform), [register](https://react-hook-form.com/docs/useform/register), [formState](https://react-hook-form.com/docs/useform/formstate), [useController](https://react-hook-form.com/docs/usecontroller), [useWatch](https://react-hook-form.com/docs/usewatch), [useFormState](https://react-hook-form.com/docs/useformstate), [useFieldArray](https://react-hook-form.com/docs/usefieldarray), [handleSubmit](https://react-hook-form.com/docs/useform/handlesubmit), [Form](https://react-hook-form.com/docs/useform/form) — as the primary source, and is written cross-checking even the type-definition source (the `react-hook-form` v7.80 line). The goal isn't enumerating APIs but going deep enough that you can judge, in practice, "**in which scene, how to write it**" yourself.

> **Versions verified (as of June 2026):** `react-hook-form` v7 line (latest 7.80), `@hookform/resolvers` v5 line, `zod` v4. v8 is not out. `@hookform/resolvers` v5 officially supports **Zod 4** and **Standard Schema** (a mechanism to handle Valibot / ArkType etc. through a common interface).

---

## 0. The Whole Picture in 30 Seconds: The One Axis Running Through RHF's Design

Capture RHF as a "convenient form library" and you'll get lost in the details. The axis running through it is one — **"confine state subscription to the place it's needed, in the amount it's needed."**

- **Holding input values** … lean on uncontrolled (`ref`); don't re-render the component while typing.
- **Validation** … delegate to Zod with `zodResolver`, consolidating type, rules, and wording into one schema.
- **State subscription** … `formState` / `watch` / `useWatch` / `useFormState` "re-render the place that reads them." So **where you read** becomes a design decision.

Just being conscious of these three avoids almost all the pitfalls discussed later (Proxy subscription, `watch`'s re-render explosion, mistaking `Controller`). From here we make this axis concrete.

---

## 1. Why RHF, and "When Not to Use It"

### 1-1. Uncontrolled × Re-Render Minimization

Build a form with `useState` and **the whole component re-renders on every single character typed**. Harmless with a few fields, but in a business form with dozens of fields, the whole thing re-renders per input, producing perceptible sluggishness and wasted computation.

RHF is **uncontrolled-component-first**, subscribing to input values directly via `ref`. The design philosophy the official docs hold up is clear —

- **Leverage uncontrolled components and native HTML inputs**
- **Avoid unnecessary computation (re-rendering)**
- **Isolate re-rendering only when needed**

As a result, **hardly any re-render occurs while typing**. The validation timing is also controllable with `mode`, balancing cost and UX.

```tsx
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";

const schema = z.object({
  name: z.string().min(2, { error: "2文字以上で入力してください" }),
  age: z.number({ error: "数値で入力してください" }).int().min(0),
});

type Schema = z.infer<typeof schema>;

function App() {
  const { register, handleSubmit } = useForm<Schema>({
    resolver: zodResolver(schema), // 検証は Zod に委譲
  });

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))} noValidate>
      <input {...register("name")} />
      {/* 数値入力は valueAsNumber で number に変換 */}
      <input type="number" {...register("age", { valueAsNumber: true })} />
      <button>送信</button>
    </form>
  );
}
```

> **The design starting point:** RHF handles "form state," Zod handles "validation rules and types." Make a single schema the **Single Source of Truth** and type, validation, and messages are unified (DRY). For how to write the Zod side, see [the Zod 4 Practical Guide](/blog/zod).

### 1-2. When to Use RHF and When Not To (Decision)

"A form = just use RHF" is actually hasty. With React 19 / Next.js, the options have grown. Choose by requirements.

| Situation | Recommendation | Reason |
| --- | --- | --- |
| A few fields, prioritize JS-less submission, no need for instant-validation UX | Plain `form` + Server Action (`useActionState`) | Zero dependencies. Works progressively |
| Medium-to-large, instant / fine-grained validation UX, dynamic fields, controlled-UI-library integration | **React Hook Form** | Re-render minimization and validation integration shine — the main pick |
| Use multiple frameworks (Solid/Vue, etc.), prioritize type-system-driven most | TanStack Form | Framework-agnostic, strict on types |
| Existing assets using Formik | Migrate to RHF | Formik is maintenance-stalled and controlled-first, with more re-renders |

The crux of the decision is two — **(1) do you need instant-validation UX, (2) is the re-render cost a problem at this scale?** If both are No, plain `form` is enough. If either is Yes, RHF is the shortest path.

---

## 2. `useForm`: The Core Settings and Return Values

`useForm` is called once per form and is the starting point of all operations. Here are the main options, with their practical effect noted.

```tsx
const {
  register,     // ネイティブ入力を登録
  handleSubmit, // 送信ハンドラを生成
  control,      // Controller / useFieldArray / useWatch に渡す
  formState,    // errors / isDirty / isValid / isSubmitting ...（Proxy。後述）
  reset,        // フォーム全体をリセット
  resetField,   // 単一フィールドだけリセット
  setValue,     // プログラムから値を設定
  getValues,    // 現在値を取得（購読しない＝再描画しない）
  watch,        // 値を購読（呼んだ場所が再描画される）
  setError,     // サーバーエラー等を手動設定
  setFocus,     // 任意フィールドにフォーカス
  trigger,      // 手動でバリデーション実行
  getFieldState,// 単一フィールドの状態を取得
} = useForm<Schema>({
  resolver: zodResolver(schema),
  defaultValues: { name: "", age: 0 }, // 重要：全フィールドの初期値を与える
  mode: "onTouched",     // 検証タイミング（下表）
  reValidateMode: "onChange", // 一度エラーになった後の再検証タイミング（既定 onChange）
  criteriaMode: "firstError", // "all" にすると1項目の全エラーを収集
  shouldFocusError: true,     // 送信失敗時に最初のエラー項目へ自動フォーカス（既定 true）
  delayError: 0,              // エラー表示を N ミリ秒遅延（連打時のチラつき抑制）
});
```

### 2-1. `mode`: Choosing the Validation Timing

| `mode` | When it validates | Where to use it |
| --- | --- | --- |
| `onSubmit` (default) | On submit | General forms where you don't want to interrupt typing |
| `onBlur` | When focus leaves | The natural UX of "point it out once they've finished typing" |
| `onTouched` | First time on blur, after that on change | Balanced (the recommended landing point) |
| `onChange` | On every input | Instant feedback (**more re-renders — caution**) |
| `all` | Both blur and change | Strictest (max cost) |

The official docs, too, state for `onChange` that "a re-render runs on every input, which can heavily impact performance." **The default of `onTouched` or `onBlur`** is recommended. Note that `reValidateMode` is the re-validation timing "after an error has appeared once," separate from the initial-validation `mode`.

### 2-2. `defaultValues`, `values`, and `errors`

- **`defaultValues`** … the initial values. They're **cached** and updated with `reset`. To output `isDirty` / `dirtyFields` correctly, **give them to all fields** (they're the baseline for the diff). Don't make `undefined` an initial value.
- **`values`** … reactive values. Use when you want to **overwrite later** with external data (API fetch). When `values` changes, the internal equivalent of `reset` runs and re-syncs.
- **`errors`** … the entry point for reactively reflecting errors passed from outside (the server). Usable for "declarative reflection of server-origin errors," paired with `values`.

```tsx
// 編集フォーム：API から取れたら自動で反映される
const { data } = useFetchUser(id);
useForm({
  defaultValues: { name: "", email: "" },
  values: data, // 取得後に上書き（リアクティブ）
});
```

### 2-3. Reading `defaultValues` Asynchronously (Loading an Edit Form)

You can pass an **`async` function** to `defaultValues` too. It's the standard for an edit form that "makes server-fetched values the initial values." While loading, `formState.isLoading` is `true`, and once initialization completes, `formState.isReady` is `true` — this lets you write even skeleton display declaratively.

```tsx
const {
  register,
  formState: { isLoading, isReady },
} = useForm<UserForm>({
  // payload は不要なら省略可。Promise を返すだけ
  defaultValues: async () => {
    const res = await fetch(`/api/users/${id}`);
    return (await res.json()) as UserForm; // 取得値がそのまま初期値に
  },
  resolver: zodResolver(userSchema),
});

if (isLoading) return <FormSkeleton />; // 取得中はスケルトン
// isReady を使えば「内部の初期化完了」までを厳密に待てる
```

> **`values` vs. async `defaultValues`:** if you're merely "pouring in" data the parent already fetched, use `values`. If you want the form itself to hold the "responsibility of fetching the initial values" and embed the loading UI, use async `defaultValues`. From an SRP view, placing data fetching outside the form (a Server Component or TanStack Query) and passing it via `values` tends to be more loosely coupled.

---

## 3. `register`: Registering Native Inputs

`register("name")` returns `{ ref, name, onChange, onBlur }`, spread onto the input. Nesting and arrays can be expressed with **dot notation** too.

```tsx
<input {...register("name")} />            // { name: value }
<input {...register("address.city")} />     // { address: { city: value } }
<input {...register("items.0.title")} />    // { items: [{ title: value }] }
```

Main options:

- `valueAsNumber` … convert the value to `number` (a must for numeric input)
- `valueAsDate` … convert to `Date`
- `setValueAs` … an arbitrary conversion function (can't be combined with `valueAsNumber` etc.)
- `disabled` … disable the input (the value becomes `undefined` and is excluded from validation)
- `deps` … fields to re-validate in tandem when this field is validated (e.g., a confirmation password)
- `onChange` / `onBlur` … when you want your own handler to run in addition to RHF's registration

```tsx
// 「パスワード」を変えたら「確認用」も再検証する
<input type="password" {...register("password", { deps: ["passwordConfirm"] })} />
```

> **Type-safety notes (official rules):** arrays support **dot notation only**. `register("items.0.title")` is ✅, `register("items[0].title")` is ❌. Also you can't clear options with `register("test", {})` or `register("test", undefined)` (state them explicitly, like `{ required: false }`). `valueAs*` runs **before** built-in validation, so write the Zod side on the premise of validating the post-conversion type (`number`, etc.).

---

## 4. `formState` Is a Proxy — The Biggest Pitfall

`formState` contains `errors` / `isDirty` / `isValid` / `isValidating` / `isSubmitting` / `isSubmitSuccessful` / `isLoading` / `isReady` / `submitCount` / `touchedFields` / `dirtyFields` / `validatingFields` / `defaultValues` / `disabled`. **This is the most-mistaken point in RHF.**

In the official words, `formState` is **wrapped in a `Proxy` for rendering performance** and **"skips the update logic for state you're not subscribed to."** That is, **unless you read it out in advance and subscribe, that value won't update**.

```tsx
// ❌ formState.isValid を条件式の中で初めて参照している
//    → Proxy が購読しないため、isValid の変化でボタンが更新されない
return <button disabled={!formState.isDirty || !formState.isValid}>送信</button>;

// ✅ レンダリング前に分割代入して「購読」する
const { isDirty, isValid } = formState;
return <button disabled={!isDirty || !isValid}>送信</button>;
```

Similarly, when monitoring with `useEffect`, put **`formState` as a whole** in the dependency array (`formState.errors` alone can fail to fire due to batched updates).

```tsx
useEffect(() => {
  if (formState.isSubmitSuccessful) reset();
}, [formState, reset]); // ✅ formState 全体を依存に
```

Get this "**read first = subscribe**" into your body and you avoid most of RHF's inscrutable "the state doesn't change" bugs. Note also that `isValid`'s evaluation timing changes depending on `mode` (with `onSubmit`, it can be optimistically `true` before submission).

---

## 5. "Design" Re-Rendering: `watch` / `useWatch` / `useFormState` / `getValues`

The biggest reason to choose RHF is performance, yet **misuse `watch` and re-rendering explodes**. Whether you can design this is where skill shows. Let's nail down the character of the four reading methods.

| Method | Subscribes? | Where it re-renders | Where to use it |
| --- | --- | --- | --- |
| `getValues()` | No | Nowhere | Just reading values on submit / inside event handlers |
| `watch("x")` | Yes | **The whole component that called `useForm`** | Small scale. Handy but large blast radius |
| `useWatch({ name })` | Yes | **Only the child that called this hook** | Isolate value display in a small child |
| `useFormState({ control })` | Yes (formState only) | **Only the child that called this hook** | Spots that only need formState, like a submit button |

The key is **"where you read = where it re-renders."** So the standard is to carve only "the leaf you want to reflect the value" into a small component and call `useWatch` there. The parent (the whole form) doesn't re-render.

```tsx
import { useForm, useWatch, type Control } from "react-hook-form";

// ✅ 合計表示だけを子に隔離。明細を打っても再描画されるのは <Total> のみ
function Total({ control }: { control: Control<InvoiceForm> }) {
  const items = useWatch({ control, name: "items" });
  const total = items.reduce((sum, i) => sum + (i.price ?? 0) * (i.qty ?? 0), 0);
  return <output>{total.toLocaleString()} 円</output>;
}

function InvoiceForm() {
  const { register, control, handleSubmit } = useForm<InvoiceForm>({
    defaultValues: { items: [{ price: 0, qty: 1 }] },
  });
  return (
    <form onSubmit={handleSubmit(save)}>
      {/* ...明細入力... */}
      <Total control={control} /> {/* ここだけ再描画される */}
    </form>
  );
}
```

Similarly, if you want only the **submit button's enablement** to look at `isValid` / `isDirty`, carve the button into a child and use `useFormState`, and the parent form doesn't re-render at all while typing.

```tsx
import { useFormState } from "react-hook-form";

function SubmitButton({ control }: { control: Control<Schema> }) {
  const { isDirty, isValid, isSubmitting } = useFormState({ control });
  return (
    <button disabled={!isDirty || !isValid || isSubmitting}>
      {isSubmitting ? "送信中…" : "送信"}
    </button>
  );
}
```

> **Anti-pattern:** calling `const values = watch();` (no argument = subscribe to all fields) right under `useForm` causes **the whole form to re-render on every character**, halving the point of using RHF. Even when you "want to see the whole," the right move is to lower `useWatch` to the displaying leaf.

---

## 6. `Controller` / `useController`: Integrating Controlled UI Libraries

**Controlled components** like shadcn/ui, MUI, Ant Design, or React-Select can't get their value via `ref`, so `register` doesn't connect to them. Here you use `Controller` (or `useController`).

```tsx
import { useForm, Controller } from "react-hook-form";
import ReactDatePicker from "react-datepicker";

function App() {
  const { control, handleSubmit } = useForm<{ publishedAt: Date }>();

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      <Controller
        control={control}
        name="publishedAt"
        render={({ field: { onChange, onBlur, value, ref }, fieldState }) => (
          <>
            <ReactDatePicker onChange={onChange} onBlur={onBlur} selected={value} ref={ref} />
            {fieldState.error && <p role="alert">{fieldState.error.message}</p>}
          </>
        )}
      />
      <button>送信</button>
    </form>
  );
}
```

The role of each property `field` provides (official):

| Property | Role |
| --- | --- |
| `onChange` | Sends the value back to RHF |
| `onBlur` | Notifies that the input was touched (focus/blur) |
| `value` | The input's current value |
| `ref` | Focuses on error |
| `name` | The field name |
| `disabled` | The whole-form / individual disabled state |

From `fieldState` you get `invalid` / `isDirty` / `isTouched` / `error`, and you can flow **that field's standalone state** straight into a11y attributes.

> **Anti-pattern:** don't apply both `register` and `Controller` (`field`) to the **same input** (`<input {...field} {...register('x')} />` is ❌). Also, in a controlled input, `onChange(undefined)` is invalid; use `null` or an empty string. To make a reusable input component, the `useController` hook is handy (next section).

---

## 7. `FormProvider` × `useController`: Making Type-Safe "Reusable Fields"

A real-world form is "dozens of identical-looking fields lined up." Hard-write `register`, `label`, `aria-*`, and error display on each field and both DRY and a11y collapse. The right move is to **distribute the form context with `FormProvider` and componentize a single field with `useController`**. Keep types safe with `FieldValues` and `Path<T>`.

```tsx
"use client";
import {
  useController,
  useFormContext,
  type FieldValues,
  type Path,
} from "react-hook-form";

type TextFieldProps<T extends FieldValues> = {
  name: Path<T>; // ✅ そのスキーマに存在するキーしか渡せない（型安全）
  label: string;
  type?: React.HTMLInputTypeAttribute;
};

// SRP：このコンポーネントの責務は「1つの入力＋ラベル＋エラー＋a11y」だけ
export function TextField<T extends FieldValues>({
  name,
  label,
  type = "text",
}: TextFieldProps<T>) {
  const { control } = useFormContext<T>();
  const { field, fieldState } = useController<T>({ name, control });
  const errorId = `${name}-error`;

  return (
    <div className="field">
      <label htmlFor={name}>{label}</label>
      <input
        id={name}
        type={type}
        aria-invalid={fieldState.invalid}
        aria-describedby={fieldState.error ? errorId : undefined}
        {...field}
      />
      {fieldState.error && (
        <p id={errorId} role="alert">
          {fieldState.error.message}
        </p>
      )}
    </div>
  );
}
```

The usage side is just this. Since `FormProvider` distributes `methods`, you don't have to keep passing `control` as a prop to child fields (prop-drilling solved).

```tsx
"use client";
import { FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

export function SignupForm() {
  const methods = useForm<Schema>({ resolver: zodResolver(schema) });

  return (
    <FormProvider {...methods}>
      <form onSubmit={methods.handleSubmit(onValid)} noValidate>
        <TextField<Schema> name="email" label="メールアドレス" type="email" />
        <TextField<Schema> name="name" label="氏名" />
        <SubmitButton control={methods.control} />
      </form>
    </FormProvider>
  );
}
```

The problems this design solves at once:

- **DRY** … consolidate the logic of a11y attributes, error display, and id numbering in one place.
- **ETC (ease of change)** … swapping the design system is only inside `TextField`.
- **Performance** … `useController` re-renders **only on that field's change**, so an input doesn't drag in other fields (§5's isolation works at the component level).
- **Type safety** … since `name` is `Path<T>`, a nonexistent key or a typo is a compile error.

---

## 8. `useFieldArray`: Dynamic Fields (Line Items, Tags, Multiple Contacts)

"Add invoice line items by row," "a variable number of tags" — dynamically adding/removing array items is `useFieldArray`'s turn.

```tsx
import { useForm, useFieldArray } from "react-hook-form";

function InvoiceForm() {
  const { register, control, handleSubmit } = useForm<{
    items: { name: string; price: number }[];
  }>({ defaultValues: { items: [{ name: "", price: 0 }] } });

  const { fields, append, remove } = useFieldArray({ control, name: "items" });

  return (
    <form onSubmit={handleSubmit((d) => console.log(d))}>
      {fields.map((field, index) => (
        // ✅ key は必ず field.id（index は不可）
        <div key={field.id}>
          <input {...register(`items.${index}.name`)} />
          <input type="number" {...register(`items.${index}.price`, { valueAsNumber: true })} />
          <button type="button" onClick={() => remove(index)}>削除</button>
        </div>
      ))}
      <button type="button" onClick={() => append({ name: "", price: 0 })}>明細を追加</button>
      <button>送信</button>
    </form>
  );
}
```

The points the official docs emphasize:

- **Always use `field.id` for `key`** (`index` is a cause of fields breaking on re-render).
- The value passed to `append` / `prepend` / `insert` must be **the complete shape, not partial** (`append({})` is ❌).
- Don't use multiple `useFieldArray` with the same `name`. `shouldUnregister: true` is unsupported in `useFieldArray`.

The operation APIs are complete: `append` / `prepend` / `insert` / `remove` / `move` / `swap` / `update` / `replace`. To display a line-item total in real time, per §5, lower `useWatch` to a leaf `<Total>` and isolate the re-rendering.

---

## 9. `handleSubmit`: Success, Failure, Server Errors, and "Double Submission" Defense

`handleSubmit(onValid, onInvalid?)` calls `onValid(data)` **on validation pass** and `onInvalid(errors)` **on validation failure**. `onValid` can be `async`.

```tsx
const {
  handleSubmit,
  setError,
  formState: { isSubmitting, errors },
} = useForm<Schema>({ resolver: zodResolver(schema) });

const onValid = async (data: Schema) => {
  try {
    await api.submit(data);
  } catch (e) {
    // 重要：handleSubmit は onValid 内の例外を握りつぶさない。必ず自分で捕捉する
    // サーバーエラーはフォーム全体エラーとして root に積む
    setError("root.server", {
      message: "送信に失敗しました。時間をおいて再試行してください。",
    });
  }
};

return (
  <form onSubmit={handleSubmit(onValid, (errs) => console.log(errs))} noValidate>
    {/* ...入力... */}
    {errors.root?.server && <p role="alert">{errors.root.server.message}</p>}
    {/* ✅ 送信中はボタンを無効化＝二重送信を構造的に防ぐ（冪等性の入口） */}
    <button disabled={isSubmitting}>{isSubmitting ? "送信中…" : "送信"}</button>
  </form>
);
```

Official note: `handleSubmit` **does not swallow** errors inside `onValid`, so `try/catch` the API call yourself and register the server-side error with `setError` (this also keeps `formState.isSubmitSuccessful` `false`).

**From a reliability view, enforce two things:**

1. **Double-submission prevention** … stop the button while submitting with `disabled={isSubmitting}`. Seal repeated clicks during network latency in the UI, and reject double execution on the server too with an idempotency key (essential for payments. See [the idempotency design](/blog/payment-double-charge-prevention-idempotency-procurement-guide)).
2. **Per-field server errors** … return field-specific errors like "this email is already in use" to the field with `setError("email", { message })`, and focus there with `shouldFocus`.

> For cases where you want to update server state (a list, etc.) after submission success, connecting the form submit to a **TanStack Query Mutation** is the standard. For the design of optimistic updates and cache invalidation, see [the TanStack Query v5 Practical Guide](/blog/tanstack-query).

---

## 10. Input Type ≠ Output Type: Type-Safe `.transform()` with the 3rd Generic

In Zod 4, `.transform()` and `z.coerce` make **input and output types diverge** (see [the Zod 4 Practical Guide](/blog/zod)). RHF catches this with the **3rd generic**. The 3-argument `useForm<TFieldValues, TContext, TTransformedValues>` expresses exactly this "before/after conversion."

```tsx
import * as z from "zod";

const schema = z.object({
  // フォーム上は文字列、送信後は number にしたい
  price: z.string().transform((s) => Number.parseInt(s, 10)),
});

type FormInput = z.input<typeof schema>;   // { price: string }  ← register が扱う型
type FormOutput = z.output<typeof schema>;  // { price: number } ← handleSubmit が受け取る型

const { register, handleSubmit } = useForm<FormInput, unknown, FormOutput>({
  resolver: zodResolver(schema),
});

handleSubmit((data) => {
  data.price; // number として型がつく（変換後）
});
```

- **1st generic** … the input type (the pre-conversion form value that `register` / `watch` handle)
- **3rd generic** … the output type (the post-conversion finalized value that `handleSubmit` receives)

This separation realizes "the screen is a string, the logic is a number" **with no type lie**. Note that `zodResolver` can adjust behavior with its 2nd argument: `zodResolver(schema, undefined, { raw: true })` returns the **pre-conversion raw value** (when you want to hold the raw value in the form and convert separately after submission). Other libraries like Valibot / ArkType can enjoy the same type separation via `standardSchemaResolver`.

---

## 11. Accessibility (a11y): The Trio You Must Implement

Form a11y isn't only "rejecting" but "**conveying what to fix and how** to assistive tech too." The official example uses `aria-invalid` and `role="alert"`. In practice, the ideal is to **associate the error text to the input with `aria-describedby`** (the `TextField` in §7 has this built in).

```tsx
const { register, formState: { errors } } = useForm<Schema>({ resolver: zodResolver(schema) });

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

The trio:

1. **`aria-invalid`** … tells assistive tech the error state.
2. **`aria-describedby`** … ties the error text (with `id`) to the input, having it read aloud on focus.
3. **`role="alert"`** … notifies the error's appearance immediately.

In addition, `shouldFocusError` (default `true`) **auto-focuses the first error field on submission failure**. Put `noValidate` on the form and unify the validation UI on RHF/Zod for consistent messages. If you want to systematize a11y including WCAG 2.2, see [the React/Next.js Accessibility Implementation Guide](/blog/react-nextjs-web-accessibility-wcag22-guide).

---

## 12. Next.js App Router: `"use client"` / Server Actions / `<Form>` / Progressive

### 12-1. The Iron Rule of Placement

`useForm` / `register` / `Controller` are **React hooks**, so always put them in a `"use client"` client component (don't write them right under `page.tsx`). The page (a Server Component) concentrates on data fetching and layout, and separates the form body into a child client component.

### 12-2. The "Double Validation" with Server Actions Is the Production Pattern

The iron rule when combining with Server Actions is — do **instant-UX validation with `zodResolver` on the client** while **re-validating with the same Zod schema on the server** (don't trust the client). Share one schema and apply it at both ends — that's **both safe and DRY**.

```tsx
// schema.ts —— クライアント／サーバー共有の単一スキーマ
export const contactSchema = z.object({
  email: z.email({ error: "メールアドレスを正しく入力してください" }),
  message: z.string().min(10, { error: "10文字以上で入力してください" }),
});
export type Contact = z.infer<typeof contactSchema>;
```

```tsx
// actions.ts —— サーバーでも必ず再検証する
"use server";
import * as z from "zod";
import { contactSchema } from "./schema";

export async function submitContact(input: unknown) {
  const parsed = contactSchema.safeParse(input); // 信頼境界はここ
  if (!parsed.success) {
    // フィールド別エラーを構造化して返す
    return { ok: false as const, errors: z.flattenError(parsed.error).fieldErrors };
  }
  await db.contacts.insert(parsed.data);
  return { ok: true as const };
}
```

```tsx
// contact-form.tsx —— クライアントは即時UX、サーバー結果を setError に反映
"use client";
const onValid = async (data: Contact) => {
  const res = await submitContact(data);
  if (!res.ok) {
    // サーバーが返した項目別エラーを RHF に流し込む（信頼の源はサーバー）
    for (const [name, messages] of Object.entries(res.errors)) {
      if (messages?.[0]) setError(name as keyof Contact, { message: messages[0] });
    }
  }
};
```

The conventions of server-side validation and shaping are detailed in [the Zod 4 Practical Guide](/blog/zod).

### 12-3. RHF's `<Form>` Component and Progressive Enhancement

RHF v7 has a **`<Form>` component** that takes over submission. Specify `action` and it handles **validation → POST via `fetch`**, calling `onSuccess` / `onError` per the result. Omit `action` and it becomes a **native submission**, the foundation that works even with JS disabled.

```tsx
import { useForm, Form } from "react-hook-form";

function Newsletter() {
  const { control, register, formState: { errors } } = useForm({
    resolver: zodResolver(contactSchema),
  });

  return (
    <Form
      action="/api/contact" // 指定で fetch POST／省略でネイティブ送信
      method="post"
      control={control}
      onSuccess={() => {/* 2xx */}}
      onError={() => setError("root.server", { message: "送信に失敗しました" })}
    >
      <label htmlFor="email">メール</label>
      <input id="email" {...register("email")} aria-invalid={!!errors.email} />
      <button>登録</button>
    </Form>
  );
}
```

Further, using `useForm({ progressive: true })` + `shouldUseNativeValidation: true` outputs **native validation attributes** like `required` / `min`, so the browser's standard validation works even before JS arrives. It's an option that pairs well with Next.js's server-centric design aiming for forms "not broken from the first render."

---

## 13. Testability: Unit-Testing a Form

Since RHF + Zod consolidates validation into one schema, **the test falls naturally into "render → operate → verify display/submission."** With `@testing-library/react` + `@testing-library/user-event`, operating **via roles/labels** verifies a11y at the same time (`getByLabelText` passing = proof that the label is correctly tied).

```tsx
// contact-form.test.tsx （jsdom 環境）
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import { ContactForm } from "./contact-form";

describe("ContactForm", () => {
  it("空送信は onSubmit を呼ばず、エラーを読み上げ領域に出す", async () => {
    const onSubmit = vi.fn();
    render(<ContactForm onSubmit={onSubmit} />);

    await userEvent.click(screen.getByRole("button", { name: "送信" }));

    expect(await screen.findByRole("alert")).toHaveTextContent(
      "メールアドレスを正しく入力してください",
    );
    expect(onSubmit).not.toHaveBeenCalled();
  });

  it("妥当な入力で整形済みデータを onSubmit に渡す", async () => {
    const onSubmit = vi.fn();
    render(<ContactForm onSubmit={onSubmit} />);

    await userEvent.type(screen.getByLabelText("メールアドレス"), "a@example.com");
    await userEvent.type(screen.getByLabelText("お問い合わせ"), "詳しい資料が欲しいです");
    await userEvent.click(screen.getByRole("button", { name: "送信" }));

    await waitFor(() =>
      expect(onSubmit).toHaveBeenCalledWith({
        email: "a@example.com",
        message: "詳しい資料が欲しいです",
      }),
    );
  });
});
```

The point is **"don't test the implementation's internal state."** Rather than reading `formState.isValid` directly, verify what the user sees (the error message, the firing of submission). This makes tests resistant to breakage even if you change RHF internals or `mode` (refactor resilience). For a design including E2E, see [the Playwright E2E Practical Guide](/blog/playwright-e2e-testing-production-design-guide).

---

## 14. A Quick Reference of Other APIs That Matter in Practice

| API | What it does | Typical use |
| --- | --- | --- |
| `reset(values?, options?)` | Initialize the whole form / re-set with given values | After submission success, on edit cancel. Control the retained range with `keepDirtyValues` etc. |
| `resetField(name)` | Initialize a single field only | "Revert just this item" |
| `setFocus(name)` | Focus an arbitrary field | Step navigation, error guidance |
| `getFieldState(name)` | Get a single field's state | Individual `invalid` / `isDirty` judgment |
| `trigger(name?)` | Fire validation manually | Validate only that step on a wizard's "Next" |
| `unregister(name)` | Unregister | Cleanup of conditionally-displayed fields |
| `disabled` (useForm) | Disable **the whole form** | Lock all inputs while submitting |
| `createFormControl()` | Generate form control outside a component | Read state independent of rendering, cross-step wizards |

`createFormControl` is a bit advanced. It **extracts `useForm`'s internals to the outside**; pass the generated `formControl` to `useForm({ formControl })` and you can read/write form state **independent of component re-rendering**. It's effective for giant multi-step wizards spanning multiple steps, or cases where you want to monitor state from logic outside the form. First consider whether `FormProvider` suffices, and use this only when truly needed (YAGNI).

---

## 15. Best Practices & Anti-Patterns

| Do | Don't |
| --- | --- |
| Delegate validation to Zod with `zodResolver` (unify type, validation, wording) | Mix RHF built-in rules with the resolver |
| Destructure and **read `formState` first to subscribe** | Reference `formState.isValid` for the first time inside a conditional |
| Give `defaultValues` for all fields | Only some / `undefined` initial values, breaking `isDirty` |
| Display values with `useWatch` at the leaf, buttons with `useFormState` | `watch()` with no argument right under `useForm`, re-rendering the whole thing |
| Controlled UI with `Controller`/`useController`, native with `register` | Try to connect a controlled component with `register` |
| Componentize reusable fields type-safely with `FormProvider` + `Path<T>` | Hard-write `register`, `aria-*` on each field, duplicated |
| `key` of `useFieldArray` is `field.id` | `key={index}`, breaking fields |
| Separate input/output types of `.transform()` with the 3rd generic | Swallow the post-conversion type with `as` |
| `aria-invalid` + `aria-describedby` + `role="alert"` | Rely on visuals alone for error text |
| While submitting, seal double submission with `disabled`/`isSubmitting` | Repeated clicks fire multiple requests |
| Server errors with `setError`, `try/catch` the API yourself | Misunderstand that `handleSubmit` handles errors for you |
| Share the same Zod schema on client/server | Client validation only, passing through the server |

---

## 16. FAQ

**Q. Why React Hook Form over writing `useState` directly?**
A. RHF is uncontrolled-first and almost eliminates re-rendering while typing. It's advantageous in all of perceived speed on large forms, code conciseness, and validation integration. Conversely, with a few fields, plain `form` + Server Action can be enough (§1-2).

**Q. How do I choose between `register` and `Controller`?**
A. Native HTML inputs (`input` / `select` / `textarea`) are `register`; controlled UI libraries whose value you can't get via `ref` (shadcn/MUI/React-Select, etc.) are `Controller`/`useController`.

**Q. Toggling the button's enablement with `isValid` doesn't work.**
A. Because `formState` is a Proxy. **Destructure and subscribe before rendering**, like `const { isValid } = formState;`. Reference it for the first time inside a conditional and it won't update (§4).

**Q. Input is heavy — the whole form re-renders on every character.**
A. Check whether you're calling `watch()` right under the form. Isolate display to a leaf `useWatch`, the button to `useFormState`, and componentize reusable fields with `useController`, and re-rendering is confined to that field (§5, §7).

**Q. I want to put initial values from an API into an edit form.**
A. If the parent already fetched, pour them in with `values`; if the form itself should hold the fetch responsibility, use async `defaultValues` + `isLoading`/`isReady` for skeleton display (§2-3).

**Q. `useForm` can't be used in Server Components.**
A. Since it's a hook, `"use client"` is required. Split server processing into a Server Action / route handler, and **re-validate with the same Zod schema** (§12).

**Q. Why choose Zod over Yup or valibot?**
A. Zod is TypeScript-first with the most natural type inference, and a thick ecosystem (`@hookform/resolvers`, `drizzle-zod`, AI SDK's structured output, etc.). If you want valibot/ArkType, connect them similarly with `standardSchemaResolver`. For details, see [the Zod 4 Practical Guide](/blog/zod).

**Q. Numbers and dates aren't extracted well.**
A. Specify `register("age", { valueAsNumber: true })` / `{ valueAsDate: true }`. Without it they're passed as strings (§3).

---

## Summary: A Form Is the Confluence of "State × Validation × Accessibility"

The key to mastering React Hook Form is capturing a form not as "a collection of input fields" but as "**a design target where state, validation, and accessibility converge**." Looking back at this article's pillars —

1. With **uncontrolled × re-render minimization**, build a snappy large form.
2. **Delegate validation to Zod with `zodResolver`**, consolidating type, validation, and wording into a single schema.
3. Understand **`formState`'s Proxy subscription** and enforce "read first."
4. With **`watch` / `useWatch` / `useFormState`**, confine re-rendering to "where you read," and make type-safe reusable fields with **`FormProvider` + `useController`**.
5. Handle controlled UI and dynamic fields with **`Controller` / `useFieldArray`**, and finish at production quality with the **a11y trio**, **double-submission prevention**, and **client/server double validation**.

These aren't shallow techniques but "**design choices**" that simultaneously raise user experience, type safety, maintainability, and performance. Apply them correctly and both the inputter's experience (accurate pointers) and the developer's experience (resistance to breakage) are lifted.

**If you need complex form design for a business system, a11y/type-safety of an existing form, or performance improvement, feel free to reach out.** The case study below introduces the process of designing and building a B2B SaaS that underpins an industry's core operations, prioritizing type safety, maintainability, and usability.
