# React Hook Form 完全ガイド【2026年最新・v7.80対応】— 型安全フォーム・再レンダリング設計・a11y・動的フィールド・Server Actions・テスト

> 公式ドキュメント最新版（React Hook Form v7.80 / @hookform/resolvers v5 / Zod 4）を一次情報に、型安全フォームの設計を本番品質で解説。zodResolver 連携、formState の Proxy 購読、watch / useWatch / useFormState による再レンダリング設計、FormProvider と型安全な再利用フィールド、useFieldArray の動的明細、async defaultValues、a11y、Server Actions の二重検証、テストまで、実コードで「いつ・どう使うか」を判断できます。

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

## Key points

- RHF は非制御コンポーネント主体で再レンダリングを最小化し、数十項目のフォームでも入力中はほぼ再描画しない（最新は v7.80 系）
- 検証は zodResolver で Zod 4 に委譲し、スキーマ1つから型・検証・エラーメッセージを単一の正として流す（DRY）
- formState は Proxy のため、isValid 等は条件式の前に分割代入で先に読んで購読しないと更新されない——最大の落とし穴
- 再レンダリングは設計対象：watch ではなく useWatch / useFormState / getValues を選び、購読をコンポーネント単位に隔離する
- FormProvider × useController で型安全な再利用フィールドを作り、a11y 3点セットとクライアント／サーバー二重検証で本番品質に仕上げる

---

この記事は React Hook Form（以下 RHF）公式ドキュメント——[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)——の最新版を一次情報とし、さらに型定義のソース（`react-hook-form` v7.80 系）まで照合して書いています。目的は API の列挙ではなく、実務で「**どの場面で・どう書くか**」を自分で判断できるところまで踏み込むことです。

> **検証した版（2026年6月時点）：** `react-hook-form` v7 系（最新 7.80）、`@hookform/resolvers` v5 系、`zod` v4。v8 は出ていません。`@hookform/resolvers` v5 で **Zod 4** と **Standard Schema**（Valibot / ArkType などを共通インターフェースで扱う仕組み）に正式対応しています。

---

## 0. 30秒で全体像：RHF の設計を貫く1本の軸

RHF を「便利なフォームライブラリ」と捉えると細部で迷います。貫く軸は1つ——**「状態の購読を、必要な場所に・必要な分だけ閉じ込める」**です。

- **入力値の保持** … 非制御（`ref`）に寄せ、入力中はコンポーネントを再描画しない。
- **検証** … `zodResolver` で Zod に委譲し、型・ルール・文言を1スキーマに集約する。
- **状態の購読** … `formState` / `watch` / `useWatch` / `useFormState` は「読んだ場所が再描画される」。だから**どこで読むか**が設計判断になる。

この3点を意識するだけで、後述の落とし穴（Proxy 購読、`watch` の再描画爆発、`Controller` の取り違え）はほぼ回避できます。以降はこの軸を具体化していきます。

---

## 1. なぜ RHF なのか、そして「いつ使わないか」

### 1-1. 非制御 × 再レンダリング最小化

`useState` でフォームを作ると、**1文字入力するたびにコンポーネント全体が再レンダリング**されます。フィールドが数個なら無害ですが、数十項目の業務フォームでは入力のたびに全体が再描画され、体感のもたつきと無駄な計算を生みます。

RHF は**非制御コンポーネント（uncontrolled）**を主体に、`ref` で入力値を直接購読します。公式が掲げる設計思想は明快です——

- **非制御コンポーネントとネイティブ HTML 入力を活用する**
- **不要な計算（再レンダリング）を避ける**
- **必要なときだけ再レンダリングを隔離する**

結果として、**入力中はほぼ再レンダリングが発生しません**。検証のタイミングも `mode` で制御でき、コストと 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>
  );
}
```

> **設計の起点：** RHF は「フォーム状態」を、Zod は「検証ルールと型」を担います。スキーマ1つを**単一の正（Single Source of Truth）**にすれば、型・検証・メッセージが一元化されます（DRY）。Zod 側の書き方は [Zod 4 実践ガイド](/blog/zod) を参照してください。

### 1-2. いつ RHF を使い、いつ使わないか（意思決定）

「フォーム＝とりあえず RHF」は実は早計です。React 19 / Next.js では選択肢が増えました。要件で選びます。

| 状況 | 推奨 | 理由 |
| --- | --- | --- |
| 数項目・JSなし送信を優先・即時検証UX不要 | 素の `form` ＋ Server Action（`useActionState`） | 依存ゼロ。プログレッシブに動く |
| 中〜大規模・即時／きめ細かい検証UX・動的フィールド・制御UIライブラリ統合 | **React Hook Form** | 再描画最小化と検証統合が効く本命 |
| 複数フレームワーク（Solid/Vue 等）も使う・型システム駆動を最重視 | TanStack Form | フレームワーク非依存・型に厳格 |
| Formik を使っている既存資産 | RHF へ移行 | Formik はメンテ停滞・制御主体で再描画が多い |

判断の核は2つ——**(1) 即時バリデーションの UX が要るか、(2) 再描画コストが問題になる規模か**。両方 No なら素の `form` で十分。どちらかが Yes なら RHF が最短距離です。

---

## 2. `useForm`：中核の設定と戻り値

`useForm` はフォーム1つにつき1回呼び出し、すべての操作の起点になります。主要オプションを実務での効き目つきで挙げます。

```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`：検証タイミングの選択

| `mode` | いつ検証するか | 使いどころ |
| --- | --- | --- |
| `onSubmit`（既定） | 送信時 | 入力中の邪魔をしたくない一般フォーム |
| `onBlur` | フォーカスを外したとき | 「入力し終えたら指摘」する自然なUX |
| `onTouched` | 初回は blur、以降は change | バランス型（おすすめの落としどころ） |
| `onChange` | 入力のたび | 即時フィードバック（**再レンダリング増・要注意**） |
| `all` | blur と change の両方 | 最も厳格（コスト最大） |

公式も `onChange` には「入力ごとに再レンダリングが走り、パフォーマンスに大きな影響が出ることがある」と明記しています。**既定は `onTouched` か `onBlur`** を推奨します。なお `reValidateMode` は「一度エラーが出た後」の再検証タイミングで、初回検証の `mode` とは別物です。

### 2-2. `defaultValues` と `values` と `errors`

- **`defaultValues`** … 初期値。**キャッシュ**され、`reset` で更新します。`isDirty` / `dirtyFields` を正しく出すには**全フィールドに与える**こと（差分の基準になるため）。`undefined` を初期値にしないこと。
- **`values`** … リアクティブな値。外部データ（API 取得）で**後から上書き**したいときに使います。`values` が変わると内部で `reset` 相当が走り再同期されます。
- **`errors`** … 外部（サーバー）から渡すエラーをリアクティブに反映する入口。`values` と対になる「サーバー由来エラーの宣言的な反映」に使えます。

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

### 2-3. `defaultValues` を非同期で読む（編集フォームのローディング）

`defaultValues` には **`async` 関数**も渡せます。「サーバーから取得した値を初期値にする」編集フォームの定石です。読み込み中は `formState.isLoading` が `true` になり、初期化が完了すると `formState.isReady` が `true` になります——これでスケルトン表示まで宣言的に書けます。

```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` と async `defaultValues` の使い分け：** すでに親が取得済みのデータを「流し込む」だけなら `values`。フォーム自身に「初期値の取得責務」を持たせ、ローディング UI も内包したいなら async `defaultValues`。SRP の観点では、データ取得はフォーム外（Server Component や TanStack Query）に置き `values` で渡すほうが疎結合になりやすいです。

---

## 3. `register`：ネイティブ入力の登録

`register("name")` は `{ ref, name, onChange, onBlur }` を返し、スプレッドで入力に展開します。ネストや配列も**ドット記法**で表現できます。

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

主なオプション：

- `valueAsNumber` … 値を `number` に変換（数値入力の必須設定）
- `valueAsDate` … `Date` に変換
- `setValueAs` … 任意の変換関数（`valueAsNumber` 等と併用不可）
- `disabled` … 入力を無効化（値は `undefined` 扱いになり検証対象から外れる）
- `deps` … この項目の検証時に連動して再検証するフィールド（例：確認用パスワード）
- `onChange` / `onBlur` … RHF の登録に加えて自前のハンドラも走らせたいとき

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

> **型安全の注意（公式ルール）：** 配列は**ドット記法のみ**対応です。`register("items.0.title")` は ✅、`register("items[0].title")` は ❌。また `register("test", {})` や `register("test", undefined)` でオプションを消すことはできません（`{ required: false }` のように明示します）。`valueAs*` は組み込み検証より**先に**走るため、Zod 側は変換後の型（`number` 等）を検証する前提で書きます。

---

## 4. `formState` は Proxy——最大の落とし穴

`formState` には `errors` / `isDirty` / `isValid` / `isValidating` / `isSubmitting` / `isSubmitSuccessful` / `isLoading` / `isReady` / `submitCount` / `touchedFields` / `dirtyFields` / `validatingFields` / `defaultValues` / `disabled` が入ります。**ここが RHF で最も間違えられるポイント**です。

公式の言葉を借りると、`formState` は**レンダリング性能のために `Proxy` でラップ**されており、**「購読していない状態は更新ロジックをスキップ」**します。つまり、**事前に読み出して購読しないと、その値は更新されません**。

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

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

同様に `useEffect` で監視するなら、依存配列には**`formState` 全体**を入れます（`formState.errors` 単体ではバッチ更新の都合で発火しないことがある）。

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

この「**先に読む＝購読する**」を体に入れておくと、RHF の不可解な「状態が変わらない」バグの大半を回避できます。`isValid` は `mode` 次第で評価タイミングが変わる点にも注意（`onSubmit` の場合、送信前は楽観的に `true` のことがある）。

---

## 5. 再レンダリングを「設計」する：`watch` / `useWatch` / `useFormState` / `getValues`

RHF を選ぶ最大の理由がパフォーマンスなのに、**`watch` の使い方を誤ると再描画が爆発**します。ここを設計できるかで実力が出ます。4つの読み取り手段の性格を押さえましょう。

| 手段 | 購読するか | 再描画する場所 | 使いどころ |
| --- | --- | --- | --- |
| `getValues()` | しない | しない | 送信時・イベントハンドラ内で値を読むだけ |
| `watch("x")` | する | **`useForm` を呼んだコンポーネント全体** | 小規模。手軽だが巻き込みが大きい |
| `useWatch({ name })` | する | **このフックを呼んだ子だけ** | 値の表示を小さな子に隔離する |
| `useFormState({ control })` | する（formState のみ） | **このフックを呼んだ子だけ** | 送信ボタンなど formState だけ要る箇所 |

要点は**「読む場所＝再描画する場所」**。だから「値を映したい末端」だけを小さなコンポーネントに切り出し、そこで `useWatch` を呼ぶのが定石です。親（フォーム全体）は再描画しません。

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

同様に、**送信ボタンの活性だけ** `isValid` / `isDirty` を見たいなら、ボタンを子に切り出して `useFormState` を使えば、親フォームは入力中まったく再描画しません。

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

> **アンチパターン：** `const values = watch();`（引数なし＝全フィールド購読）を `useForm` 直下で呼ぶと、**1文字ごとにフォーム全体が再描画**され、RHF を使う意味が半減します。「全体を見たい」ときも、表示する末端に `useWatch` を下ろすのが正解です。

---

## 6. `Controller` / `useController`：制御 UI ライブラリの統合

shadcn/ui・MUI・Ant Design・React-Select のような**制御コンポーネント**は `ref` で値を取れないため、`register` では繋がりません。ここで `Controller`（または `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>
  );
}
```

`field` が提供する各プロパティの役割（公式）：

| プロパティ | 役割 |
| --- | --- |
| `onChange` | 値を RHF に送り返す |
| `onBlur` | 入力に触れた（フォーカス／ブラー）ことを通知 |
| `value` | 入力の現在値 |
| `ref` | エラー時にフォーカスを当てる |
| `name` | フィールド名 |
| `disabled` | フォーム全体／個別の無効化状態 |

`fieldState` からは `invalid` / `isDirty` / `isTouched` / `error` が取れ、**そのフィールド単独の状態**を a11y 属性にそのまま流せます。

> **アンチパターン：** `register` と `Controller`（`field`）を**同じ入力に両方適用しない**こと（`<input {...field} {...register('x')} />` は ❌）。また制御入力では `onChange(undefined)` は不正で、`null` か空文字を使います。再利用可能な入力コンポーネントを作るなら、フックの `useController` が便利です（次節）。

---

## 7. `FormProvider` × `useController`：型安全な「再利用フィールド」を作る

実務のフォームは「同じ見た目のフィールドが何十個も並ぶ」もの。各フィールドに `register`・`label`・`aria-*`・エラー表示をベタ書きすると、DRY も a11y も崩れます。**`FormProvider` でフォーム文脈を配り、`useController` で1フィールドを部品化**するのが正攻法です。型は `FieldValues` と `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>
  );
}
```

使う側はこれだけ。`FormProvider` で `methods` を配るので、子フィールドに `control` をプロップで渡し続ける必要がありません（バケツリレーの解消）。

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

この設計が同時に解く課題：

- **DRY** … a11y 属性・エラー表示・id 連番のロジックを1か所に集約。
- **ETC（変えやすさ）** … デザインシステム差し替えは `TextField` の中だけ。
- **パフォーマンス** … `useController` は**そのフィールドの変化でだけ**再描画されるため、入力が他フィールドを巻き込みません（§5 の隔離が部品レベルで効く）。
- **型安全** … `name` は `Path<T>` なので、存在しないキーや打ち間違いはコンパイルエラー。

---

## 8. `useFieldArray`：動的フィールド（明細・タグ・複数連絡先）

「請求明細を行で追加」「タグを可変個」——配列項目の動的な追加・削除は `useFieldArray` の出番です。

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

公式が強調する要点：

- **`key` には必ず `field.id`** を使う（`index` は再レンダリングでフィールドが壊れる原因）。
- `append` / `prepend` / `insert` に渡す値は**部分でなく完全な形**であること（`append({})` は ❌）。
- 同じ `name` で複数の `useFieldArray` を使わない。`useFieldArray` では `shouldUnregister: true` は非対応。

操作 API は `append` / `prepend` / `insert` / `remove` / `move` / `swap` / `update` / `replace` が揃っています。明細の合計をリアルタイム表示するなら、§5 のとおり `useWatch` を末端の `<Total>` に下ろして再描画を隔離してください。

---

## 9. `handleSubmit`：成功・失敗・サーバーエラーと「二重送信」対策

`handleSubmit(onValid, onInvalid?)` は、**検証通過時** `onValid(data)`、**検証失敗時** `onInvalid(errors)` を呼びます。`onValid` は `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>
);
```

公式の注意：`handleSubmit` は `onValid` 内のエラーを**飲み込まない**ため、API 呼び出しは自分で `try/catch` し、`setError` でサーバー側エラーを登録します（これにより `formState.isSubmitSuccessful` も `false` のままになります）。

**信頼性の観点で2つ徹底します：**

1. **二重送信防止** … `disabled={isSubmitting}` で送信中はボタンを止める。ネットワーク遅延中の連打を UI で封じ、サーバー側でも冪等キーで二重実行を弾く（決済では必須。[冪等性の設計](/blog/payment-double-charge-prevention-idempotency-procurement-guide) 参照）。
2. **フィールド単位のサーバーエラー** … 「このメールは既に使われています」のような項目別エラーは `setError("email", { message })` で該当フィールドに戻し、`shouldFocus` でそこへフォーカスさせる。

> 送信成功後にサーバー状態（一覧など）を更新したいケースは、フォーム送信を **TanStack Query の Mutation** に繋ぐのが定石です。楽観的更新やキャッシュ無効化の設計は [TanStack Query v5 実践ガイド](/blog/tanstack-query) を参照してください。

---

## 10. 入力型 ≠ 出力型：`.transform()` を第3ジェネリクスで型安全に

Zod 4 では `.transform()` や `z.coerce` で**入力型と出力型がズレ**ます（[Zod 4 実践ガイド](/blog/zod)参照）。RHF はこれを**第3ジェネリクス**で受け止めます。`useForm<TFieldValues, TContext, TTransformedValues>` という3引数が、まさにこの「変換前／変換後」を表現します。

```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 として型がつく（変換後）
});
```

- **第1ジェネリクス** … 入力型（`register` / `watch` が扱う、変換前のフォーム値）
- **第3ジェネリクス** … 出力型（`handleSubmit` が受け取る、変換後の確定値）

この分離により、「画面は文字列・ロジックは数値」を**型の嘘なし**で実現できます。なお `zodResolver` は第2引数で挙動を調整でき、`zodResolver(schema, undefined, { raw: true })` とすると**変換前の生値**を返します（フォームには生値を保持し、変換は送信後に別途行いたい場合）。Valibot / ArkType など他ライブラリでも、`standardSchemaResolver` を使えば同じ型分離の恩恵を受けられます。

---

## 11. アクセシビリティ（a11y）：実装すべき3点セット

フォームの a11y は「弾く」だけでなく「**何をどう直すか**を支援技術にも伝える」ことです。公式例は `aria-invalid` と `role="alert"` を用います。実務では**`aria-describedby` でエラー文を入力に関連付ける**のが理想です（§7 の `TextField` はこれを内蔵しています）。

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

3点セット：

1. **`aria-invalid`** … エラー状態を支援技術へ伝える。
2. **`aria-describedby`** … エラー文（`id` 付き）を入力に紐づけ、フォーカス時に読み上げさせる。
3. **`role="alert"`** … エラー出現を即時に通知。

加えて、`shouldFocusError`（既定 `true`）が**送信失敗時に最初のエラー項目へ自動フォーカス**します。フォームには `noValidate` を付け、検証 UI を RHF/Zod に一本化するとメッセージが一貫します。a11y を WCAG 2.2 まで含めて体系化したい場合は [React/Next.js アクセシビリティ実装ガイド](/blog/react-nextjs-web-accessibility-wcag22-guide) を参照してください。

---

## 12. Next.js App Router：`"use client"` / Server Actions / `<Form>` / プログレッシブ

### 12-1. 置き場所の鉄則

`useForm` / `register` / `Controller` は**React フック**なので、必ず **`"use client"`** のクライアントコンポーネントに置きます（`page.tsx` 直下に書かない）。ページ（Server Component）はデータ取得とレイアウトに専念し、フォーム本体を子のクライアントコンポーネントに分離します。

### 12-2. Server Actions との「二重検証」が本番の型

Server Actions と併用する場合の鉄則は——**クライアント側で `zodResolver` による即時 UX 検証**を行いつつ、**サーバー側でも同じ Zod スキーマで再検証**することです（クライアントは信用しない）。スキーマは1つを共有し、両端で適用するのが**安全と 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] });
    }
  }
};
```

サーバー側のバリデーション・整形の作法は [Zod 4 実践ガイド](/blog/zod) に詳述しています。

### 12-3. RHF の `<Form>` コンポーネントとプログレッシブ・エンハンスメント

RHF v7 には送信を一手に引き受ける **`<Form>` コンポーネント**があります。`action` を指定すると**検証 → `fetch` で POST**まで面倒を見て、結果に応じて `onSuccess` / `onError` を呼びます。`action` を省略すれば**ネイティブ送信**になり、JS が無効でも動く土台になります。

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

さらに `useForm({ progressive: true })` ＋ `shouldUseNativeValidation: true` を使うと、`required` / `min` などの**ネイティブ検証属性**が出力され、JS 到達前でもブラウザ標準の検証が効きます。「最初の描画から壊れていない」フォームを目指す Next.js のサーバー中心設計と相性が良い選択肢です。

---

## 13. テスト容易性：フォームのユニットテスト

RHF ＋ Zod は検証を1スキーマに集約するため、**テストは「描画 → 操作 → 表示・送信を検証」**の形に素直に落ちます。`@testing-library/react` ＋ `@testing-library/user-event` で、**ロール／ラベル経由**に操作すると a11y まで同時に検証できます（`getByLabelText` が通る＝ラベルが正しく結びついている証拠）。

```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: "詳しい資料が欲しいです",
      }),
    );
  });
});
```

ポイントは**「実装の内部状態をテストしない」**こと。`formState.isValid` を直接読むのではなく、ユーザーが見るもの（エラーメッセージ・送信の発火）を検証します。これにより、RHF の内部や `mode` を変えてもテストが壊れにくくなります（リファクタ耐性）。E2E まで含めた設計は [Playwright E2E 実践ガイド](/blog/playwright-e2e-testing-production-design-guide) を参照してください。

---

## 14. 実務で効くその他 API 早見表

| API | 何をするか | 典型的な使いどころ |
| --- | --- | --- |
| `reset(values?, options?)` | フォーム全体を初期化／指定値で再設定 | 送信成功後・編集キャンセル。`keepDirtyValues` 等で保持範囲を制御 |
| `resetField(name)` | 単一フィールドだけ初期化 | 「この項目だけ元に戻す」 |
| `setFocus(name)` | 任意フィールドへフォーカス | ステップ移動・エラー誘導 |
| `getFieldState(name)` | 単一フィールドの状態を取得 | 個別の `invalid` / `isDirty` 判定 |
| `trigger(name?)` | 手動で検証を発火 | ウィザードの「次へ」で当該ステップだけ検証 |
| `unregister(name)` | 登録解除 | 条件付き表示フィールドの後始末 |
| `disabled`（useForm） | **フォーム全体**を無効化 | 送信中に全入力をロック |
| `createFormControl()` | コンポーネント外でフォーム制御を生成 | 描画に依存せず状態を読む・ウィザード横断 |

`createFormControl` は少し上級です。`useForm` の内部実装を**外に取り出す**もので、生成した `formControl` を `useForm({ formControl })` に渡せば、**コンポーネントの再描画に依存せず**フォーム状態を読み書きできます。複数ステップにまたがる巨大ウィザードや、フォーム外のロジックから状態を監視したいケースで効きます。まずは `FormProvider` で足りないか検討し、本当に必要なときだけ使ってください（YAGNI）。

---

## 15. ベストプラクティス＆アンチパターン

| やるべきこと（Do） | 避けるべきこと（Don't） |
| --- | --- |
| 検証は `zodResolver` で Zod に委譲（型・検証・文言を一元化） | RHF 組み込みルールと resolver を混在させる |
| `formState` は分割代入で**先に読んで購読** | 条件式の中で初めて `formState.isValid` を参照 |
| `defaultValues` は全フィールド分を与える | 一部だけ／`undefined` を初期値にして `isDirty` が壊れる |
| 値の表示は末端で `useWatch`、ボタンは `useFormState` | `useForm` 直下で引数なし `watch()` し全体を再描画 |
| 制御 UI は `Controller`／`useController`、ネイティブは `register` | 制御コンポーネントを `register` で繋ごうとする |
| 再利用フィールドは `FormProvider` ＋ `Path<T>` で型安全に部品化 | 各フィールドに `register`・`aria-*` をベタ書きで重複 |
| `useFieldArray` の `key` は `field.id` | `key={index}` でフィールドが壊れる |
| `.transform()` は第3ジェネリクスで入出力型を分離 | 変換後の型を `as` で握りつぶす |
| `aria-invalid` ＋ `aria-describedby` ＋ `role="alert"` | エラー文を視覚だけに頼る |
| 送信中は `disabled`／`isSubmitting` で二重送信を封じる | 連打でリクエストが多重発火する |
| サーバーエラーは `setError`、API は自分で `try/catch` | `handleSubmit` がエラーを処理してくれると誤解 |
| クライアント／サーバーで同じ Zod スキーマを共有 | クライアント検証だけでサーバーを素通し |

---

## 16. FAQ（よくある質問）

**Q. なぜ `useState` 直書きより React Hook Form なのですか？**
A. RHF は非制御主体で、入力中の再レンダリングをほぼ排除します。大規模フォームでの体感速度・コードの簡潔さ・検証統合のすべてで有利です。逆に数項目なら素の `form` ＋ Server Action で十分なこともあります（§1-2）。

**Q. `register` と `Controller` の使い分けは？**
A. ネイティブ HTML 入力（`input` / `select` / `textarea`）は `register`、`ref` で値を取れない制御 UI ライブラリ（shadcn/MUI/React-Select 等）は `Controller`／`useController` です。

**Q. `isValid` でボタンの活性を切り替えると動きません。**
A. `formState` が Proxy だからです。`const { isValid } = formState;` のように**レンダリング前に分割代入して購読**してください。条件式の中で初めて参照すると更新されません（§4）。

**Q. 入力が重い・1文字ごとにフォーム全体が再描画されます。**
A. `watch()` をフォーム直下で呼んでいないか確認を。表示は末端の `useWatch`、ボタンは `useFormState` に隔離し、再利用フィールドは `useController` で部品化すると、再描画がそのフィールドに閉じます（§5・§7）。

**Q. 編集フォームで API から初期値を入れたい。**
A. 親が取得済みなら `values` で流し込み、フォーム自身に取得責務を持たせるなら async `defaultValues` ＋ `isLoading`／`isReady` でスケルトン表示します（§2-3）。

**Q. Server Components で `useForm` が使えません。**
A. フックなので `"use client"` 必須です。サーバー処理は Server Action／ルートハンドラに分け、**同じ Zod スキーマで再検証**します（§12）。

**Q. Yup や valibot ではなく Zod を選ぶ理由は？**
A. Zod は TypeScript ファーストで型推論が最も自然、エコシステムも厚い（`@hookform/resolvers`、`drizzle-zod`、AI SDK の構造化出力など）。valibot/ArkType を使いたい場合も `standardSchemaResolver` で同様に繋げます。詳細は [Zod 4 実践ガイド](/blog/zod)。

**Q. 数値や日付がうまく取れません。**
A. `register("age", { valueAsNumber: true })`／`{ valueAsDate: true }` を指定してください。指定しないと文字列のまま渡ります（§3）。

---

## まとめ：フォームは「状態 × 検証 × アクセシビリティ」の合流点

React Hook Form を使いこなす鍵は、フォームを「入力欄の集合」ではなく「**状態・検証・アクセシビリティが合流する設計対象**」として捉えることです。本記事の柱を振り返ると——

1. **非制御 × 再レンダリング最小化**で、軽快な大規模フォームを作る。
2. **`zodResolver` で Zod に検証を委譲**し、型・検証・文言を単一スキーマに集約する。
3. **`formState` の Proxy 購読**を理解し、「先に読む」を徹底する。
4. **`watch` / `useWatch` / `useFormState`** で再描画を「読む場所」に閉じ込め、**`FormProvider` ＋ `useController`** で型安全な再利用フィールドを作る。
5. **`Controller` / `useFieldArray`** で制御 UI と動的フィールドを扱い、**a11y 3点セット**・**二重送信防止**・**クライアント／サーバー二重検証**で本番品質に仕上げる。

これらは小手先のテクニックではなく、ユーザー体験・型安全性・保守性・パフォーマンスを同時に引き上げる「**設計の選択**」です。正しく適用すれば、入力者の体験（的確な指摘）と開発者の体験（壊れにくさ）の両方が底上げされます。

**業務システムの複雑なフォーム設計・既存フォームの a11y/型安全化・パフォーマンス改善が必要な場合は、お気軽にご相談ください。** 下記の事例では、業界の基幹業務を支える B2B SaaS を、型安全・保守性・ユーザビリティを重視して設計・実装した過程を紹介しています。
