Skip to main content
友田 陽大
React forms
React
React Hook Form
TypeScript
パフォーマンス
Next.js
フォーム
フロントエンド

React Hook Form performance optimization [2026 latest] — control re-renders and lighten large forms

A measurement-first practical guide that uncovers why React Hook Form is fast (uncontrolled) and the causes that still make it slow. With production-quality real code it explains the use of watch / useWatch / useFormState / getValues, design that isolates subscription to the component level, Controller × React.memo, how to handle a useFieldArray of hundreds of rows with virtualization, mode and validation cost, and the impact on INP.

Published
Reading time
11 min read
Author
友田 陽大
Share

"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-form v7 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):

  1. Calling watch() directly under the form, subscribing to the whole.
  2. With mode: "onChange" / "all", all-field validation runs per keystroke.
  3. A heavy Controller's render re-renders even on a neighboring field's change.
  4. A useFieldArray has 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

MethodSubscribeWhere it re-rendersWhen to use
getValues()nodoesn'tonly reading values on submit / inside an event
watch("x")yesthe whole component that called useFormsmall scale. Easy but large blast radius
useWatch({ name })yesonly the child that called this hookisolate value display to the end
useFormState({ control })yes (formState only)only the child that called this hooksubmit-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: control is a stable reference and name/label are primitives, so memo works. Conversely, passing a render function or an object newly created each time as props nullifies memo. memo isn't omnipotent and works only "when the props are stable" — first build the foundation with §4's subscription isolation, then add memo on 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.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>
  );
}

Points:

  • key must always be fields[index].id. Use index and fields break on virtualization scroll.
  • Even if a row is unmounted by virtualization, with shouldUnregister: false the input value stays in RHF state and all rows are obtained on submit.
  • Put the total and row-count badge with useWatch at 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.

modeValidation frequencyCostRecommendation
onSubmit (default)only on submitminimalgeneral forms where you don't want to disturb input
onBluron focus outlownatural pointing
onTouchedchange after the first blurmediumbalanced, recommended as the base
onChangeper keystrokehighlimit only to some parts where immediacy is needed
allboth blur and changemaximumavoid 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 defaultValues a 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 the values prop.
  • 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 subscription watch((values) => ...) in useEffect, 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)

AntipatternWhat happensRemedy
watch() directly under useFormwhole re-render per inputuseWatch at the displaying end
Reference formState.isValid in the parent for button activationparent re-render per inputchild the button and useFormState
Routine use of mode: "all"all validation per keystrokebase on onTouched + trigger only where needed
key={index} of useFieldArrayvalues break on reorder/virtualizationkey={field.id}
Pile virtualization/memo on a small formpure increase in complexity, hotbed of bugsmeasure first, only where needed
Newly generate defaultValues each timeform re-initializationmake 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

  1. Grasp the principle that where you read = where it re-renders.
  2. Measurement-first. Aim for "only the field you're typing lights up" with the Profiler.
  3. Stop watch, push useWatch / useFormState down to the end.
  4. Split heavy Controllers + memo, large line items with a field.id key + virtualization.
  5. Base mode on onTouched, and localize immediacy with trigger/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.

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

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.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading