メインコンテンツへスキップ
友田 陽大
Reactフォーム実装
React
React Hook Form
TypeScript
パフォーマンス
Next.js
フォーム
フロントエンド

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

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

公開日
読了時間
11分
著者
友田 陽大
シェア

React Hook Form(RHF)を「とりあえず入れたのに、なぜか入力が重い」——その原因のほぼ全ては再レンダリングの設計にあります。この記事は RHF 公式の Advanced UsageuseWatch / useFormState を一次情報に、「速さを設計する」方法を計測ファーストで掘り下げます。RHF の基礎は 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. 重い Controllerrender が、隣のフィールドの変化でも再描画されている。
  4. useFieldArray の行が数百あり、全行を常時描画している。

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


2. 計測ファースト(推測でチューニングしない)

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

  • React DevTools Profiler:記録 → 入力 → どのコンポーネントが点灯するかを見る。理想は「打っている1フィールドだけ」点灯。
  • 簡易レンダーカウンタ:開発時に挿す。
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 だけ要るなら useFormStatewatch() は最終手段。


4. 購読を末端へ隔離する(最重要パターン)

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

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 を下ろす

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. 送信ボタンの活性も子に隔離

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

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

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(既定)なら値は消えません。

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.idindex 不可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].idindex を使うと仮想化のスクロールでフィールドが壊れます。
  • 仮想化で行が unmount されても、shouldUnregister: false なので入力値は RHF 状態に残り、送信時に全行が取れます。
  • 合計や行数バッジは §4 のとおり useWatch を末端に。

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


7. mode と検証コスト

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

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

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

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

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


8. その他の効きどころ

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

9. アンチパターンと対処(早見表)

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

10. FAQ

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

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

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

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

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


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

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

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

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

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

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事で解説した技術の適用事例

経済産業大臣賞受賞 | 木材流通業界のDXを実現したB2BサブスクリプションSaaS

ケーススタディを見る