"I introduced React Hook Form (RHF) for now, but somehow input is heavy" — almost all of that cause is in the design of re-rendering. This article, with RHF's official Advanced Usage, useWatch, and useFormState as primary sources, digs into "how to design for speed" measurement-first. RHF basics assume the complete React Hook Form guide.
Verified versions (as of June 2026):
react-hook-formv7 family, React 19, Next.js 16.
0. The big principle: "where you read" = "where it re-renders"
RHF's performance boils down to a single principle — the component that read the form's state re-renders on changes to that state. So making it fast = "pushing the read location down to the truly necessary end."
Conversely, the most common failure is calling watch() directly under the parent component that called useForm. This alone causes "the whole form re-renders per keystroke," and the meaning of using RHF disappears. This article lands this principle into concrete code.
1. Why RHF is fast / where it gets slow
Why it's fast (uncontrolled): a form built with useState causes setState → component re-render on each input. RHF is uncontrolled-centric, holding input values directly in ref, so it doesn't re-render at all during input.
Causes of slowdown (4):
- Calling
watch()directly under the form, subscribing to the whole. - With
mode: "onChange"/"all", all-field validation runs per keystroke. - A heavy
Controller'srenderre-renders even on a neighboring field's change. - A
useFieldArrayhas hundreds of rows, all rendered constantly.
Below, we measure and crush each.
2. Measurement-first (don't tune by guessing)
Before optimizing, visualize which component re-renders how many times.
- React DevTools Profiler: record → input → see which components light up. The ideal is "only the one field you're typing in" lights up.
- A simple render counter: insert during development.
function useRenderCount(label: string) {
const count = useRef(0);
count.current += 1;
// 本番ビルドでは出さない(開発時の可視化用)
if (process.env.NODE_ENV !== "production") {
console.log(`[render] ${label}: ${count.current}`);
}
}
First confirm "whether the parent form is +1'd on each input." If it is, the cause is almost certainly §3–§4.
3. The correct choice of the 4 read methods
| Method | Subscribe | Where it re-renders | When to use |
|---|---|---|---|
getValues() | no | doesn't | only reading values on submit / inside an event |
watch("x") | yes | the whole component that called useForm | small scale. Easy but large blast radius |
useWatch({ name }) | yes | only the child that called this hook | isolate value display to the end |
useFormState({ control }) | yes (formState only) | only the child that called this hook | submit-button activation, etc. |
The decision flow: if you don't want to re-render (submit, compute), getValues. If you want to display a value on screen, push useWatch down to that "displaying end." If you only need isDirty/isValid, useFormState. watch() is the last resort.
4. Isolate subscription to the end (the most important pattern)
4-1. ❌ Antipattern: subscribe to the whole in the parent
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. ✅ Correct: push useWatch down to the displaying end
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>
);
}
The difference is dramatic. In the Profiler, 4-1 lights up all inputs, while 4-2 lights up only <InvoiceTotal>. The more "a value you want to see across the form," the more the iron rule is to push it down to the displaying end.
4-3. Isolate the submit button's activation to a child too
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>
);
}
Read formState.isValid directly under useForm and the parent re-renders, but carve the button into a child and use useFormState and the parent doesn't re-render at all during input.
5. Field-level isolation of Controller + React.memo
Controller / useController re-renders only on that field's change. Land this into a reusable part and each field becomes independent of each other.
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;
Note on
memo:controlis a stable reference andname/labelare primitives, somemoworks. Conversely, passing arenderfunction or an object newly created each time as props nullifiesmemo.memoisn't omnipotent and works only "when the props are stable" — first build the foundation with §4's subscription isolation, then addmemoon top, in that order (KISS).
6. Handle a large useFieldArray (hundreds of rows) with virtualization
When line items reach hundreds of rows, just keeping all rows in the DOM constantly gets heavy. Render only visible rows with @tanstack/react-virtual. The reason it's compatible with RHF is that the values are held not in the DOM but in RHF's state — even if a row is unmounted off-screen, with shouldUnregister: false (default) the value doesn't disappear.
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>
);
}
Points:
keymust always befields[index].id. Useindexand fields break on virtualization scroll.- Even if a row is unmounted by virtualization, with
shouldUnregister: falsethe input value stays in RHF state and all rows are obtained on submit. - Put the total and row-count badge with
useWatchat the end as in §4.
Warning against over-optimization (YAGNI): virtualization is meaningful only at the hundreds-of-rows scale. Adding virtualization to a form with 10–30 line items is a pure increase in complexity. First confirm "whether it's actually heavy" with the Profiler before introducing it.
7. mode and validation cost
Validation is not free. zodResolver runs the target field (sometimes the whole) through Zod.
mode | Validation frequency | Cost | Recommendation |
|---|---|---|---|
onSubmit (default) | only on submit | minimal | general forms where you don't want to disturb input |
onBlur | on focus out | low | natural pointing |
onTouched | change after the first blur | medium | balanced, recommended as the base |
onChange | per keystroke | high | limit only to some parts where immediacy is needed |
all | both blur and change | maximum | avoid in principle |
The practical landing point: the whole is onTouched, and only some parts that need immediate feedback (password strength, etc.) manually fire trigger("password") on onChange — this hybrid is light and good for UX. For linked validation, narrow the target with deps to avoid re-validating unrelated fields.
// パスワードを変えたら確認用だけ再検証(全体は走らせない)
<input type="password" {...register("password", { deps: ["passwordConfirm"] })} />
If error-display flicker bothers you, you can delay the display with delayError (milliseconds).
8. Other effective spots
- Make
defaultValuesa stable reference: passing a new object each render can re-initialize the form. Make it a constant, or if it's API-derived, pass it via thevaluesprop. - Split heavy children: make a large
Controller(rich editor, etc.) an independent component so it doesn't re-render on a neighboring field's change. - The callback version of
watch: if you only want to flow a side effect on the whole's change, there's also the option of using a callback subscriptionwatch((values) => ...)inuseEffect, which doesn't cause a render (always unsubscribe the subscription). - Relation to INP: excessive re-rendering per input blocks the main thread and worsens INP (Interaction to Next Paint). Form optimization directly ties not only to perceived speed but to Core Web Vitals (Core Web Vitals optimization guide).
9. Anti-patterns and remedies (quick reference)
| Antipattern | What happens | Remedy |
|---|---|---|
watch() directly under useForm | whole re-render per input | useWatch at the displaying end |
Reference formState.isValid in the parent for button activation | parent re-render per input | child the button and useFormState |
Routine use of mode: "all" | all validation per keystroke | base on onTouched + trigger only where needed |
key={index} of useFieldArray | values break on reorder/virtualization | key={field.id} |
| Pile virtualization/memo on a small form | pure increase in complexity, hotbed of bugs | measure first, only where needed |
Newly generate defaultValues each time | form re-initialization | make it a constant / supply via values |
10. FAQ
Q. Input is heavy. What do I look at first?
A. Record with React DevTools Profiler while typing one character, and confirm whether the parent form lights up. If it does, calling watch() directly under the parent is the prime suspect (§4).
Q. What's the difference between watch and useWatch?
A. watch re-renders the component that called useForm. useWatch re-renders only the component that called it. So placing useWatch at the "displaying end" is the standard.
Q. I want to display the total of all fields in real time.
A. Make a small component that displays the total, and inside it useWatch({ control, name: "items" }). The parent form doesn't re-render (§4-2).
Q. Is RHF OK even for a form with 100+ items?
A. It's OK. Rather, it's RHF's exclusive stage. Isolate subscription to the end, memo the Controller if needed, and virtualize line items. It becomes overwhelmingly lighter than direct useState or a control-centric library.
Q. Does putting memo on all fields make it fast?
A. No. memo works only when the props are stable. First build the foundation with subscription isolation (§4), and add it where needed after measuring.
Conclusion: speed is not a "feature" but "design"
React Hook Form's performance is not something the library gives you for free but a design outcome decided by where you place subscription —
- Grasp the principle that where you read = where it re-renders.
- Measurement-first. Aim for "only the field you're typing lights up" with the Profiler.
- Stop
watch, pushuseWatch/useFormStatedown to the end. - Split heavy
Controllers +memo, large line items with afield.idkey + virtualization. - Base
modeononTouched, and localize immediacy withtrigger/deps.
These bring not only "a fast form" but improved INP, improved maintainability, and fewer bugs at the same time. Form lag directly ties to departure = lost opportunity in deal forms and application forms. That's exactly why optimization is an investment in both UX and business.
If you need performance improvement of large, high-frequency-input forms, or a redesign of existing forms, feel free to reach out. The case study below introduces the process of designing and implementing the form group of a B2B SaaS supporting the core operations of an industry, with emphasis on performance, type safety, and maintainability.