# React Hook Form × Zod 実践ガイド【2026年最新・v7対応】— 型安全フォーム・再レンダリング最小化・a11y・動的フィールド・Server Actions

> 公式ドキュメント最新版（React Hook Form v7 / @hookform/resolvers v5 / Zod 4）に忠実な実践ガイド。zodResolver 連携、再レンダリングを最小化する非制御アーキテクチャと formState の Proxy 購読、Controller による制御コンポーネント統合、useFieldArray の動的フィールド、a11y、Server Actions まで、本番品質のコード例で「いつ・どう使うか」を解説します。

- 公開日: 2026-06-24
- 著者: 友田 陽大
- タグ: React, React Hook Form, Zod, TypeScript, フォーム, 型安全, パフォーマンス, フロントエンド
- URL: https://tomodahinata.com/blog/react-hook-form

## 要点

- React Hook Form は非制御コンポーネント主体で再レンダリングを最小化し、大規模フォームでも入力中はほぼ再描画しない
- 検証は zodResolver で Zod に委譲し、スキーマ1つから型・検証・エラーメッセージを単一の正として流す
- formState は Proxy のため、isValid 等は条件式の前に分割代入で先に読んで購読しないと更新が反映されない
- ネイティブ入力は register、ref で値を取れない制御 UI は Controller / useController で繋ぐのが設計の起点
- a11y は aria-invalid + aria-describedby + role=alert の3点セットを守り、クライアントとサーバーで同じ Zod を二重検証する

---

この記事は React Hook Form 公式ドキュメント（[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)、[useFieldArray](https://react-hook-form.com/docs/usefieldarray)、[handleSubmit](https://react-hook-form.com/docs/useform/handlesubmit)）の最新版を一次情報として、実務で「どの場面で・どう書くか」を判断できるところまで踏み込みます。

---

## 1. なぜ React Hook Form なのか（非制御 × 再レンダリング最小化）

`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: "数値で入力してください" }),
});

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))}>
      <input {...register("name")} />
      {/* 数値入力は valueAsNumber で number に変換 */}
      <input type="number" {...register("age", { valueAsNumber: true })} />
      <input type="submit" />
    </form>
  );
}
```

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

---

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

`useForm` はフォーム1つにつき1回呼び出し、すべての操作の起点になります。

```tsx
const {
  register, // ネイティブ入力を登録
  handleSubmit, // 送信ハンドラを生成
  control, // Controller / useFieldArray に渡す
  formState, // errors / isDirty / isValid / isSubmitting ...
  reset, // フォーム全体をリセット
  setValue, // プログラムから値を設定
  getValues, // 現在値を取得（購読しない）
  watch, // 値を購読（再レンダリングを伴う）
  setError, // サーバーエラー等を手動設定
  trigger, // 手動でバリデーション実行
} = useForm<Schema>({
  resolver: zodResolver(schema),
  defaultValues: { name: "", age: 0 }, // 重要：全フィールドの初期値を与える
  mode: "onBlur", // 検証タイミング（下表）
});
```

### 2-1. `mode`：検証タイミングの選択

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

公式も `onChange` には「入力ごとに再レンダリングが走り、パフォーマンスに大きな影響が出ることがある」と明記しています。**既定は `onTouched` か `onBlur`** を推奨します。

### 2-2. `defaultValues` と `values` の違い

- **`defaultValues`** … 初期値。**キャッシュ**され、`reset` で更新します。`isDirty` / `dirtyFields` を正しく出すには**全フィールドに与える**こと（差分の基準になるため）。`undefined` を初期値にしないこと。
- **`values`** … リアクティブな値。外部データ（API 取得）で**後から上書き**したいときに使います。

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

---

## 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` … 任意の変換関数
- `disabled` … 入力を無効化（値は `undefined` になる）
- `deps` … 連動して再検証するフィールド

> **型安全の注意（公式ルール）：** 配列は**ドット記法のみ**対応です。`register("items.0.title")` は ✅、`register("items[0].title")` は ❌。また `register("test", {})` や `register("test", undefined)` でオプションを消すことはできません（`{ required: false }` のように明示します）。

---

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

`formState` には `errors` / `isDirty` / `isValid` / `isSubmitting` / `isSubmitSuccessful` / `touchedFields` / `dirtyFields` / `isValidating` などが入ります。**ここが 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 の不可解な「状態が変わらない」バグの大半を回避できます。

---

## 5. `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>}
          </>
        )}
      />
      <input type="submit" />
    </form>
  );
}
```

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

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

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

---

## 6. `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>
      <input type="submit" />
    </form>
  );
}
```

公式が強調する要点：

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

操作 API は `append` / `prepend` / `insert` / `remove` / `move` / `swap` / `update` / `replace` が揃っています。

---

## 7. `handleSubmit`：成功・失敗とサーバーエラー

`handleSubmit(onValid, onInvalid?)` は、**検証通過時** `onValid(data)`、**検証失敗時** `onInvalid(errors)` を呼びます。`onValid` は `async` 可。

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

<form onSubmit={handleSubmit(onValid, (errors) => console.log(errors))} noValidate>
```

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

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

---

## 8. Zod の `.transform()` を型安全に扱う（入力型 ≠ 出力型）

Zod 4 では `.transform()` や `z.coerce` で**入力型と出力型がズレ**ます（[Zod 4 実践ガイド](/blog/zod)参照）。RHF はこれを**第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` が受け取る、変換後の確定値）

この分離により、「画面は文字列・ロジックは数値」を**型の嘘なし**で実現できます。

---

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

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

```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 に一本化するとメッセージが一貫します。

---

## 10. Next.js App Router での注意（"use client" / Server Actions）

`useForm` / `register` / `Controller` は**React フック**なので、必ず **`"use client"`** のクライアントコンポーネントに置きます（`page.tsx` 直下に書かない）。

Server Actions と併用する場合は、**クライアント側で `zodResolver` による即時 UX 検証**を行いつつ、**サーバー側でも同じ Zod スキーマで再検証**します（クライアントは信用しない）。スキーマは1つを共有し、両端で適用するのが安全と DRY の両立です。サーバー側検証・整形の作法は [Zod 4 実践ガイド](/blog/zod) に詳述しています。

---

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

| やるべきこと（Do） | 避けるべきこと（Don't） |
| --------------------------------------------------------------- | ------------------------------------------------------------ |
| 検証は `zodResolver` で Zod に委譲（型・検証・文言を一元化） | RHF 組み込みルールと resolver を混在させる |
| `formState` は分割代入で**先に読んで購読** | 条件式の中で初めて `formState.isValid` を参照 |
| `defaultValues` は全フィールド分を与える | 一部だけ／`undefined` を初期値にして `isDirty` が壊れる |
| 制御 UI は `Controller`、ネイティブは `register` | 制御コンポーネントを `register` で繋ごうとする |
| `useFieldArray` の `key` は `field.id` | `key={index}` でフィールドが壊れる |
| `.transform()` は第3ジェネリクスで入出力型を分離 | 変換後の型を `as` で握りつぶす |
| `aria-invalid` + `aria-describedby` + `role="alert"` | エラー文を視覚だけに頼る |
| サーバーエラーは `setError`、API は自分で `try/catch` | `handleSubmit` がエラーを処理してくれると誤解 |
| クライアント／サーバーで同じ Zod スキーマを共有 | クライアント検証だけでサーバーを素通し |

---

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

**Q. なぜ `useState` 直書きより React Hook Form なのですか？**
A. RHF は非制御主体で、入力中の再レンダリングをほぼ排除します。大規模フォームでの体感速度・コードの簡潔さ・検証統合のすべてで有利です。

**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;` のように**レンダリング前に分割代入して購読**してください。条件式の中で初めて参照すると更新されません。

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

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

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

---

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

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

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

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

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