مرکزی مواد پر جائیں
友田 陽大
React forms
React
React Hook Form
TypeScript
パフォーマンス
Next.js
フォーム
フロントエンド

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

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

Published
Reading time
11 min read
Author
友田 陽大
شیئر کریں

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 のフォーム群を、パフォーマンス・型安全・保守性を重視して設計・実装した過程を紹介しています。

友田

友田 陽大

وزیر (METI) ایوارڈ یافتہ پروڈکٹ کے ڈویلپر۔ TypeScript + Python + AWS کے ساتھ میں SaaS، صنعتی ڈیجیٹل تبدیلی اور پروڈکشن کے قابل جنریٹو اے آئی (RAG) تنہا ابتدا سے انتہا تک فراہم کرتا ہوں — تقاضوں کی تعیین سے انفراسٹرکچر اور آپریشن تک۔

Got a challenge?

From design to implementation and operations — solo × generative AI

Implementation like this article's, end to end from requirements to production. Start with a free 30-minute technical consult and tell me about your situation.

پروجیکٹ کی بنیاد پر کام اور تکنیکی مشاورت دونوں کے لیے دستیاب ہوں۔ 30 منٹ کی مفت مشاورت سے آغاز کریں۔

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

یہ بھی پڑھنے کے قابل