# 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: 2026-06-26
- Author: 友田 陽大
- Tags: React, React Hook Form, TypeScript, パフォーマンス, Next.js, フォーム, フロントエンド
- URL: https://tomodahinata.com/en/blog/react-hook-form-performance-rerender-optimization-guide
- Category: React forms
- Pillar guide: https://tomodahinata.com/en/blog/react-hook-form

## Key points

- RHF is fast because it's uncontrolled. It gets slow when you fail to design 'where you subscribed = where it re-renders.'
- For reads, choose by requirements: getValues (no subscription) / watch (the whole that called it) / useWatch (only the child that called it) / useFormState (formState only).
- Push useWatch down to a small end component for value display and cut off the re-render of the whole form.
- Handle a useFieldArray of hundreds of rows with React.memo + virtualization (@tanstack/react-virtual) to render only visible rows. Values stay in RHF state so it doesn't break.
- mode like onChange/all is more costly. Measure (Profiler), then base on onTouched and validate immediately only where needed.

---

"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](https://react-hook-form.com/advanced-usage), [useWatch](https://react-hook-form.com/docs/usewatch), and [useFormState](https://react-hook-form.com/docs/useformstate) as primary sources, digs into "how to design for speed" measurement-first. RHF basics assume the [complete React Hook Form guide](/blog/react-hook-form).

> **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.

```tsx
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

```tsx
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

```tsx
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

```tsx
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.

```tsx
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.

```tsx
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:

- `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.

| `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.

```tsx
// パスワードを変えたら確認用だけ再検証（全体は走らせない）
<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](/blog/core-web-vitals-nextjs-inp-lcp-cls-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** —

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 `Controller`s + `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.
