skip to content

A 200-field React Hook Form form lags on every keystroke after watch() was added in the form component for a live summary — why, and what would you use instead?

level: seniorimportance: should knowfreq 40%

answer

  1. who subscribed, and where
  2. root-level subscription
  3. isolate in a child
  4. read without subscribing

basics

~20 s

watch() in React Hook Form's useForm component subscribes that component to value changes, so every keystroke re-renders all 200 fields. Move the read into a child with useWatch, or call getValues when you only need values at an event.

solid answer

~40 s

`watch()` re-renders **at the level of `useForm`** — the documentation says it triggers a re-render at the root of the form. Without arguments it watches every field, so each keystroke re-renders the component that renders all 200 inputs. Fixes, in order of preference: move the summary into a child that calls `useWatch({ control, name: [...] })`, which isolates re-renders to that child; use `getValues()` in event handlers, because it reads current values without subscribing; use `useFormState({ control })` for flags such as `isDirty` so the root does not read them; and for side effects like autosave, use `subscribe` (since 7.55.0), which reacts to changes without re-rendering. Also check `mode: 'onChange'` with a resolver, which validates the schema on every keystroke.

code

tsx · 14 lines
tsx
import { useWatch, type Control } from 'react-hook-form';

type Order = { plan: 'basic' | 'pro'; seats: number };
const PRICE = { basic: 5, pro: 12 } as const;

// Only this component re-renders when plan or seats change.
export function PriceSummary({ control }: { control: Control<Order> }) {
  const [plan, seats] = useWatch({ control, name: ['plan', 'seats'] });
  return <p>Total: {PRICE[plan] * (seats || 0)} per month</p>;
}

// Inside the form component, instead of const all = watch():
//   <PriceSummary control={control} />
//   <button type="button" onClick={() => confirmOrder(getValues())}>Review</button>

go deeper

for a junior

Know the three names and which one subscribes: watch and useWatch do, getValues does not.

for a middle

Explain that the difference between watch and useWatch is only the level that re-renders, and why that matters for field count.

for a senior

Profile a lagging form, locate the root subscription, and move reads into useWatch, useFormState, getValues or subscribe as each case needs.

for a principal

Set conventions for large forms, such as no root watch and isolated summary components, and back them with a profiling budget.

## Three ways to read values React Hook Form keeps field values outside React state, so reading them is a choice about **who gets re-rendered**. The three common tools differ exactly there: | API | Subscribes? | What re-renders | Typical use | |---|---|---|---| | `watch(names?)` | Yes | The component that called `useForm` | Small forms, conditional fields at the root | | `useWatch({ control, name })` | Yes | Only the component calling it | Live summaries, dependent widgets in large forms | | `getValues(names?)` | No | Nothing | Reading values inside handlers and callbacks | Two related tools complete the picture: - `useFormState({ control })` isolates **form-state** subscriptions (`isDirty`, `errors`, `isValid`) in a child, the way `useWatch` isolates values. - `subscribe({ name, formState, callback })`, added in 7.55.0, lets you react to value or form-state changes **without** re-rendering — right for autosave or analytics. ## Why the root watch hurts `watch()` is documented to trigger a re-render at the root of the form. Called without arguments it watches every field. In a 200-field form that means every keystroke re-renders the form component, which re-renders every field element beneath it. Registered inputs are cheap to re-render, but 200 of them per keystroke — plus any heavy widgets — shows up as input lag. The summary panel that needed the values is a small fraction of that work. ## Fixing it, step by step 1. **Confirm the subscriber.** Record a React profiler trace while typing; the form component will show as re-rendering on each keystroke. 2. **Move the summary into its own component** and read values there with `useWatch({ control, name: ['plan', 'seats'] })`. Only that component re-renders when those fields change. 3. **Narrow the names.** Watching specific fields instead of the whole form avoids re-renders for unrelated changes. 4. **Use `getValues()` where you only need a value at a moment** — inside a click handler, before a network call, in a confirm dialog. 5. **Move form-state reads out of the root** with `useFormState` in the component that needs them, such as the submit button. 6. **For side effects, use `subscribe`**, returning its unsubscribe function from an effect. ## Other things that make the root re-render - Reading `formState` properties at the root: the Proxy subscribes the root to each one it reads. - `mode: 'onChange'` or `'all'` with a resolver: validation runs on every keystroke, and when errors change, `errors` subscribers re-render; the resolver also runs against the whole form's values each time. - Controlled widgets are fine inside `Controller`, but lifting their value into root state with `useState` brings back the root re-render. ## Pitfalls with the replacements - **`useWatch` needs `control`** (or a `FormProvider` above it). - **Initial values.** `useWatch` returns the current form value when one exists; its `defaultValue` (or `useForm`'s `defaultValues`) is only a fallback before the form has mounted. Provide `defaultValues` so the first render is not `undefined`. - **`watch` results are for rendering**, not for `useEffect` dependency arrays; the documentation points you to comparison hooks or `subscribe` for detecting updates. - **`getValues` is not reactive.** Rendering `getValues('plan')` shows a value that goes stale; that is the trade you accept for zero subscriptions.

  • In React Hook Form, when is watch() at the root still the right choice?
    In a small form where a value decides what renders at the root, such as showing a company field when an account type is 'business'. The root re-render is cheap there, and moving it into a child adds indirection for no gain. The cost matters as the field count or widget weight grows.
  • Why does rendering getValues('plan') in JSX show a stale value in React Hook Form?
    `getValues` reads the current value but does not subscribe, so nothing re-renders the component when the value changes. It shows whatever was current during the last render that happened for some other reason. Use `useWatch` for anything displayed, and `getValues` inside handlers.
  • What does useWatch return on its first render in React Hook Form?
    The current form value if one is available. Its own `defaultValue` option, or `useForm`'s `defaultValues`, is used only as a fallback before the form has mounted and no value exists yet. Supplying `defaultValues` to `useForm` keeps the first render from being `undefined`.

watch() at the root is a building-wide announcement for every change, heard in all 200 offices; useWatch is a message to the one office that asked, and getValues is walking over to read the noticeboard when you need it.

saying these in an interview costs you the question

  • watch() only re-renders the input being watched
  • getValues() re-renders the component when the value changes
  • useWatch and watch have identical re-render cost
  • Wrapping each input in React.memo is the main fix for a root watch
  • The lag must come from register, so switch every field to Controller