React Hook Form(RHF)を「とりあえず入れたのに、なぜか入力が重い」——その原因のほぼ全ては再レンダリングの設計にあります。この記事は RHF 公式の Advanced Usage と useWatch / useFormState を一次情報に、「速さを設計する」方法を計測ファーストで掘り下げます。RHF の基礎は React Hook Form 完全ガイド を前提とします。
検証した版(2026年6月時点):
react-hook-formv7 系、React 19、Next.js 16。
0. 大原則:「読む場所」=「再描画する場所」
RHF のパフォーマンスは、たった1つの原則に集約されます——フォームの状態を読んだコンポーネントが、その状態の変化で再描画される。だから速くする=**「読む場所を、本当に必要な末端まで下ろす」**ことです。
逆に最も多い失敗は、useForm を呼んだ親コンポーネントの直下で watch() を呼ぶこと。これだけで「1文字ごとにフォーム全体が再描画」され、RHF を使う意味が消えます。本記事は、この原則を具体的なコードに落としていきます。
1. なぜ RHF は速いのか/どこで遅くなるのか
速い理由(非制御): useState で作るフォームは、入力のたびに setState → コンポーネント再描画が起きます。RHF は入力値を ref で直接保持する非制御主体なので、入力中はそもそも再描画しません。
遅くなる原因(4つ):
watch()をフォーム直下で呼び、全体を購読している。mode: "onChange"/"all"で、1文字ごとに全フィールド検証が走る。- 重い
Controllerのrenderが、隣のフィールドの変化でも再描画されている。 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 だけ要るなら useFormState。watch() は最終手段。
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
Controller/useController はそのフィールドの変化でだけ再描画されます。これを再利用部品に落とすと、各フィールドが互いに独立します。
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.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 で対象を絞り、無関係なフィールドの再検証を避けます。
// パスワードを変えたら確認用だけ再検証(全体は走らせない)
<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 最適化ガイド)。
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 のパフォーマンスは、ライブラリが勝手にくれるものではなく、あなたが購読をどこに置くかで決まる設計成果です——
- 読む場所=再描画する場所という原則を握る。
- 計測ファースト。Profiler で「打っているフィールドだけ点灯」を目標にする。
watchをやめ、末端にuseWatch/useFormStateを下ろす。- 重い
Controllerは分割+memo、大規模明細はfield.idキー+仮想化。 modeはonTouched基準、即時性はtrigger/depsで局所化する。
これらは「速いフォーム」だけでなく、INP の改善・保守性の向上・バグの減少を同時にもたらします。フォームのもたつきは、商談フォームや申込フォームでは離脱=機会損失に直結します。だからこそ最適化は UX とビジネスの両方の投資です。
大規模・高頻度入力フォームのパフォーマンス改善や、既存フォームの再設計が必要な場合は、お気軽にご相談ください。 下記の事例では、業界の基幹業務を支える B2B SaaS のフォーム群を、パフォーマンス・型安全・保守性を重視して設計・実装した過程を紹介しています。