# React Hook Form パフォーマンス最適化【2026年最新】— 再レンダリングを制御し大規模フォームを軽くする

> React Hook Form が速い理由（非制御）と、それでも遅くなる原因を計測ファーストで解き明かす実践ガイド。watch / useWatch / useFormState / getValues の使い分け、購読をコンポーネント単位に隔離する設計、Controller × React.memo、数百行の useFieldArray を仮想化で捌く方法、mode と検証コスト、INP への影響までを、本番品質の実コードで解説します。

- 公開日: 2026-06-26
- 著者: 友田 陽大
- タグ: React, React Hook Form, TypeScript, パフォーマンス, Next.js, フォーム, フロントエンド
- URL: https://tomodahinata.com/blog/react-hook-form-performance-rerender-optimization-guide

## 要点

- RHF が速いのは非制御だから。遅くなるのは『どこで購読したか＝どこが再描画するか』を設計し損ねたとき
- 読み取りは getValues（購読なし）/ watch（呼んだ全体）/ useWatch（呼んだ子だけ）/ useFormState（formStateだけ）を要件で選ぶ
- 値の表示は末端の小コンポーネントに useWatch を下ろし、フォーム全体の再描画を断ち切る
- 数百行の useFieldArray は React.memo＋仮想化（@tanstack/react-virtual）で可視行だけ描画する。値は RHF 状態に残るので壊れない
- mode は onChange/all ほど高コスト。計測（Profiler）してから onTouched を基準に、必要な箇所だけ即時検証する

---

React Hook Form（RHF）を「とりあえず入れたのに、なぜか入力が重い」——その原因のほぼ全ては**再レンダリングの設計**にあります。この記事は [RHF 公式の Advanced Usage](https://react-hook-form.com/advanced-usage) と [useWatch](https://react-hook-form.com/docs/usewatch) / [useFormState](https://react-hook-form.com/docs/useformstate) を一次情報に、「速さを設計する」方法を計測ファーストで掘り下げます。RHF の基礎は [React Hook Form 完全ガイド](/blog/react-hook-form) を前提とします。

> **検証した版（2026年6月時点）：** `react-hook-form` v7 系、React 19、Next.js 16。

---

## 0. 大原則：「読む場所」＝「再描画する場所」

RHF のパフォーマンスは、たった1つの原則に集約されます——**フォームの状態を読んだコンポーネントが、その状態の変化で再描画される**。だから速くする＝**「読む場所を、本当に必要な末端まで下ろす」**ことです。

逆に最も多い失敗は、`useForm` を呼んだ親コンポーネントの**直下で `watch()` を呼ぶ**こと。これだけで「1文字ごとにフォーム全体が再描画」され、RHF を使う意味が消えます。本記事は、この原則を具体的なコードに落としていきます。

---

## 1. なぜ RHF は速いのか／どこで遅くなるのか

**速い理由（非制御）：** `useState` で作るフォームは、入力のたびに `setState` → コンポーネント再描画が起きます。RHF は入力値を `ref` で直接保持する**非制御**主体なので、**入力中はそもそも再描画しません**。

**遅くなる原因（4つ）：**

1. `watch()` をフォーム直下で呼び、全体を購読している。
2. `mode: "onChange"` / `"all"` で、1文字ごとに全フィールド検証が走る。
3. 重い `Controller` の `render` が、隣のフィールドの変化でも再描画されている。
4. `useFieldArray` の行が数百あり、全行を常時描画している。

以降、それぞれを計測して潰します。

---

## 2. 計測ファースト（推測でチューニングしない）

最適化の前に、**どのコンポーネントが何回再描画されているか**を可視化します。

- **React DevTools Profiler**：記録 → 入力 → どのコンポーネントが点灯するかを見る。理想は「打っている1フィールドだけ」点灯。
- **簡易レンダーカウンタ**：開発時に挿す。

```tsx
function useRenderCount(label: string) {
  const count = useRef(0);
  count.current += 1;
  // 本番ビルドでは出さない（開発時の可視化用）
  if (process.env.NODE_ENV !== "production") {
    console.log(`[render] ${label}: ${count.current}`);
  }
}
```

「親フォームが入力のたびに +1 されていないか」をまず確認してください。されていれば原因はほぼ §3〜§4 です。

---

## 3. 読み取り4手段の正しい選択

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

**判断のフロー：** 再描画したくない（送信・計算）なら `getValues`。値を画面に映したいなら、その「映す末端」に `useWatch` を下ろす。`isDirty`/`isValid` だけ要るなら `useFormState`。`watch()` は最終手段。

---

## 4. 購読を末端へ隔離する（最重要パターン）

### 4-1. ❌ アンチパターン：親で全体購読

```tsx
function InvoiceForm() {
  const { register, watch } = useForm<Invoice>();
  const items = watch("items"); // ❌ ここで購読 → 入力のたびにフォーム全体が再描画
  const total = items.reduce((s, i) => s + i.price * i.qty, 0);
  return (
    <form>
      {/* ...大量の入力... */}
      <p>合計 {total} 円</p>
    </form>
  );
}
```

### 4-2. ✅ 正解：表示する末端に `useWatch` を下ろす

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

// この子だけが items の変化で再描画される。親フォームは静かなまま
function InvoiceTotal({ control }: { control: Control<Invoice> }) {
  const items = useWatch({ control, name: "items" });
  const total = items.reduce((s, i) => s + (i.price ?? 0) * (i.qty ?? 0), 0);
  return <output className="text-lg font-bold">{total.toLocaleString()} 円</output>;
}

function InvoiceForm() {
  const { register, control, handleSubmit } = useForm<Invoice>({
    defaultValues: { items: [{ price: 0, qty: 1 }] },
  });
  return (
    <form onSubmit={handleSubmit(save)}>
      {/* ...大量の入力（親は再描画されない）... */}
      <InvoiceTotal control={control} />
    </form>
  );
}
```

**差は劇的です。** Profiler で見ると、4-1 は全入力が点灯、4-2 は `<InvoiceTotal>` だけが点灯します。「フォーム全体で見たい値」ほど、表示する末端に下ろすのが鉄則です。

### 4-3. 送信ボタンの活性も子に隔離

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

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

`useForm` 直下で `formState.isValid` を読むと親が再描画されますが、ボタンを子に切り出して `useFormState` を使えば、**親は入力中いっさい再描画されません**。

---

## 5. `Controller` のフィールド単位の隔離 ＋ `React.memo`

`Controller`／`useController` は**そのフィールドの変化でだけ**再描画されます。これを再利用部品に落とすと、各フィールドが互いに独立します。

```tsx
import { useController, type Control, type FieldValues, type Path } from "react-hook-form";
import { memo } from "react";

type FieldProps<T extends FieldValues> = {
  control: Control<T>;
  name: Path<T>;
  label: string;
};

function TextFieldBase<T extends FieldValues>({ control, name, label }: FieldProps<T>) {
  const { field, fieldState } = useController({ control, name });
  return (
    <div>
      <label htmlFor={name}>{label}</label>
      <input id={name} aria-invalid={fieldState.invalid} {...field} />
      {fieldState.error && <p role="alert">{fieldState.error.message}</p>}
    </div>
  );
}

// memo で、無関係な親再描画時の再評価を防ぐ（props が同じなら再描画しない）
export const TextField = memo(TextFieldBase) as typeof TextFieldBase;
```

> **`memo` の注意：** `control` は安定参照、`name`/`label` はプリミティブなので `memo` が効きます。逆に `render` 関数や毎回新規生成するオブジェクトを props で渡すと `memo` は無効化されます。`memo` は万能ではなく「props が安定しているとき」だけ効く——まず §4 の購読隔離で土台を作り、その上で `memo` を足すのが順序です（KISS）。

---

## 6. 大規模 `useFieldArray`（数百行）を仮想化で捌く

明細が数百行になると、全行を常時 DOM に置くだけで重くなります。**`@tanstack/react-virtual` で可視行だけ描画**します。RHF と相性が良い理由は、**値が DOM ではなく RHF の状態に保持される**から——行が画面外で unmount されても、`shouldUnregister: false`（既定）なら値は消えません。

```tsx
import { useForm, useFieldArray } from "react-hook-form";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useRef } from "react";

function LargeLineItems() {
  const { control, register } = useForm<{ rows: Row[] }>({
    defaultValues: { rows: initialRows }, // 例：500行
    shouldUnregister: false, // 既定。画面外の値を保持する
  });
  const { fields } = useFieldArray({ control, name: "rows" });

  const parentRef = useRef<HTMLDivElement>(null);
  const virtualizer = useVirtualizer({
    count: fields.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 48, // 1行の高さ
    overscan: 8,
  });

  return (
    <div ref={parentRef} className="h-[600px] overflow-auto">
      <div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
        {virtualizer.getVirtualItems().map((v) => {
          const index = v.index;
          return (
            <div
              key={fields[index].id} // ✅ field.id（index 不可）
              style={{ position: "absolute", top: 0, transform: `translateY(${v.start}px)`, width: "100%" }}
            >
              <input {...register(`rows.${index}.name`)} />
              <input type="number" {...register(`rows.${index}.qty`, { valueAsNumber: true })} />
            </div>
          );
        })}
      </div>
    </div>
  );
}
```

ポイント：

- `key` は必ず `fields[index].id`。`index` を使うと仮想化のスクロールでフィールドが壊れます。
- 仮想化で行が unmount されても、`shouldUnregister: false` なので入力値は RHF 状態に残り、送信時に全行が取れます。
- 合計や行数バッジは §4 のとおり `useWatch` を末端に。

> **過剰最適化への警告（YAGNI）：** 仮想化は**数百行級**で初めて意味があります。明細が10〜30行のフォームに仮想化を入れるのは複雑性の純増です。まず Profiler で「実際に重いか」を確認してから導入してください。

---

## 7. `mode` と検証コスト

検証は**ただではありません**。`zodResolver` は対象フィールド（場合により全体）を Zod に通します。

| `mode` | 検証頻度 | コスト | 推奨 |
| --- | --- | --- | --- |
| `onSubmit`（既定） | 送信時のみ | 最小 | 入力を邪魔したくない一般フォーム |
| `onBlur` | フォーカスアウト時 | 低 | 自然な指摘 |
| `onTouched` | 初回blur以降change | 中 | バランス型・基準におすすめ |
| `onChange` | 1文字ごと | 高 | 即時性が要る一部だけに限定 |
| `all` | blurとchange両方 | 最大 | 原則避ける |

**実務の落としどころ：** 全体は `onTouched`、即時フィードバックが要る一部（パスワード強度など）だけ `trigger("password")` を `onChange` で手動発火、という**ハイブリッド**が軽くて UX も良い。連動検証は `deps` で対象を絞り、無関係なフィールドの再検証を避けます。

```tsx
// パスワードを変えたら確認用だけ再検証（全体は走らせない）
<input type="password" {...register("password", { deps: ["passwordConfirm"] })} />
```

エラー表示のチラつきが気になるなら `delayError`（ミリ秒）で表示を遅延できます。

---

## 8. その他の効きどころ

- **`defaultValues` を安定参照に：** 毎レンダリングで新しいオブジェクトを渡すとフォームが再初期化されることがあります。定数化するか、API 由来なら `values` プロップで渡す。
- **重い子は分割：** 大きな `Controller`（リッチエディタ等）は独立コンポーネントにし、隣接フィールドの変化で再描画されないようにする。
- **`watch` のコールバック版：** 全体の変化に副作用を流したいだけなら、`useEffect` で `watch((values) => ...)` のコールバック購読を使い、レンダリングを発生させない選択もある（購読は必ず unsubscribe）。
- **INP との関係：** 入力ごとの過剰再描画はメインスレッドを塞ぎ、**INP（Interaction to Next Paint）**を悪化させます。フォーム最適化は体感速度だけでなく Core Web Vitals に直結します（[Core Web Vitals 最適化ガイド](/blog/core-web-vitals-nextjs-inp-lcp-cls-optimization-guide)）。

---

## 9. アンチパターンと対処（早見表）

| アンチパターン | 何が起きる | 対処 |
| --- | --- | --- |
| `useForm` 直下で `watch()` | 入力ごとに全体再描画 | 表示する末端で `useWatch` |
| ボタン活性に `formState.isValid` を親で参照 | 入力ごとに親再描画 | ボタンを子化し `useFormState` |
| `mode: "all"` を常用 | 1文字ごとに全検証 | `onTouched` 基準＋必要箇所だけ `trigger` |
| `useFieldArray` の `key={index}` | 並べ替え・仮想化で値が壊れる | `key={field.id}` |
| 小規模フォームに仮想化・memo を盛る | 複雑性の純増・バグ温床 | 計測してから、必要な箇所だけ |
| `defaultValues` を毎回新規生成 | フォーム再初期化 | 定数化／`values` で供給 |

---

## 10. FAQ

**Q. 入力が重いです。最初に何を見ればいい？**
A. React DevTools Profiler で記録しながら1文字打ち、**親フォームが点灯していないか**を確認。点灯していれば `watch()` の親直下呼び出しが第一容疑者です（§4）。

**Q. `watch` と `useWatch` の違いは？**
A. `watch` は `useForm` を呼んだコンポーネントを再描画。`useWatch` は**それを呼んだコンポーネントだけ**を再描画。だから「映す末端」に `useWatch` を置くのが定石です。

**Q. 全フィールドの合計をリアルタイム表示したい。**
A. 合計を表示する小コンポーネントを作り、その中で `useWatch({ control, name: "items" })`。親フォームは再描画されません（§4-2）。

**Q. 100項目超のフォームでも RHF で大丈夫？**
A. 大丈夫です。むしろ RHF の独擅場。購読を末端に隔離し、必要なら `Controller` を `memo`、明細は仮想化。`useState` 直書きや制御主体ライブラリより圧倒的に軽くなります。

**Q. `memo` を全フィールドに付ければ速くなる？**
A. なりません。`memo` は props が安定しているときだけ効きます。まず購読隔離（§4）で土台を作り、計測した上で必要箇所に足してください。

---

## まとめ：速さは「機能」ではなく「設計」

React Hook Form のパフォーマンスは、ライブラリが勝手にくれるものではなく、**あなたが購読をどこに置くかで決まる設計成果**です——

1. **読む場所＝再描画する場所**という原則を握る。
2. **計測ファースト**。Profiler で「打っているフィールドだけ点灯」を目標にする。
3. **`watch` をやめ、末端に `useWatch` / `useFormState`** を下ろす。
4. **重い `Controller` は分割＋`memo`**、**大規模明細は `field.id` キー＋仮想化**。
5. **`mode` は `onTouched` 基準**、即時性は `trigger`/`deps` で局所化する。

これらは「速いフォーム」だけでなく、INP の改善・保守性の向上・バグの減少を同時にもたらします。フォームのもたつきは、商談フォームや申込フォームでは**離脱＝機会損失**に直結します。だからこそ最適化は UX とビジネスの両方の投資です。

**大規模・高頻度入力フォームのパフォーマンス改善や、既存フォームの再設計が必要な場合は、お気軽にご相談ください。** 下記の事例では、業界の基幹業務を支える B2B SaaS のフォーム群を、パフォーマンス・型安全・保守性を重視して設計・実装した過程を紹介しています。
