Overview
watch and useWatch are React Hook Form APIs for observing form values, but they affect rendering differently. watch is a method returned by useForm and is convenient when the component that owns the form needs to read values. useWatch is a hook that subscribes to form values from a smaller component and isolates rerenders at that hook/component level.
This matters because large forms can become slow when every keystroke rerenders the entire form tree. React Hook Form's performance model depends on uncontrolled inputs, scoped subscriptions, and avoiding unnecessary root-level rerenders. If a top-level form component calls watch() for the whole form, every value change can make the whole form component rerender. If smaller field sections use useWatch, only the component that depends on that value needs to update.
In production React apps, this topic appears in long profile forms, checkout forms, onboarding flows, admin edit screens, dynamic questionnaires, form builders, nested field arrays, and any page where form state affects conditional UI.
For interviews, this topic is important because it tests whether a candidate understands subscription scope, form state isolation, controlled versus uncontrolled inputs, and practical performance debugging. A good answer is not "never use watch." A good answer is "watch at the level where the value is actually needed."
Core Concepts
React Hook Form's Subscription Model
React Hook Form avoids rerendering every field on every keystroke by default. Native inputs registered with register can keep their current value in the DOM, while React Hook Form tracks refs, changes, validation, dirty state, touched state, and submit state internally.
Rerenders happen when React components subscribe to state that changes.
Common subscriptions:
- A component reads
formState.errors. - A component reads
formState.isDirty. - A component calls
watch("fieldName"). - A component calls
watch()for the whole form. - A component uses
useWatch. - A component uses
useFormState. - A controlled field uses
ControlleroruseController.
The performance question is: which component subscribed, and how much of the form did it subscribe to?
What watch Does
watch is returned by useForm.
const { register, watch } = useForm<FormValues>();
const plan = watch("plan");
Common forms:
const value = watch("fieldName");
const values = watch(["firstName", "lastName"]);
const allValues = watch();
watch is useful when the same component that owns the form needs values for rendering. For example, a small form might show an extra field when a checkbox is checked.
function SmallSignupForm() {
const { register, watch } = useForm<SignupValues>({
defaultValues: {
hasCompany: false,
companyName: "",
},
});
const hasCompany = watch("hasCompany");
return (
<form>
<label>
<input type="checkbox" {...register("hasCompany")} />
I am signing up for a company
</label>
{hasCompany ? (
<input {...register("companyName")} placeholder="Company name" />
) : null}
</form>
);
}
This is fine for small forms. It can become expensive when the component calling watch renders a large tree.
What useWatch Does
useWatch is a hook that subscribes to value changes from a component. Its main benefit is render isolation.
function CompanyFields({ control }: { control: Control<SignupValues> }) {
const hasCompany = useWatch({
control,
name: "hasCompany",
});
if (!hasCompany) {
return null;
}
return <input {...register("companyName")} />;
}
In real code, the child component usually receives control and register, or it uses FormProvider and useFormContext.
function CompanyFields() {
const { control, register } = useFormContext<SignupValues>();
const hasCompany = useWatch({
control,
name: "hasCompany",
});
if (!hasCompany) {
return null;
}
return <input {...register("companyName")} placeholder="Company name" />;
}
Now only CompanyFields needs to rerender when hasCompany changes. The root form component does not need to rerender just to decide whether this section is visible.
watch vs useWatch
The practical difference is subscription placement.
Use watch when:
- The form is small.
- The root form component genuinely needs the value.
- The watched value affects a small amount of UI.
- You are debugging or quickly prototyping.
- You need a one-off conditional render near the form owner.
Use useWatch when:
- A child component needs a form value.
- The form is large.
- Only one section should rerender.
- The watched value drives expensive UI.
- A field array row needs its own derived behavior.
- A reusable component should subscribe to its own field.
The rule of thumb: if the value only affects a subsection, subscribe inside that subsection.
Whole-Form Watching
watch() with no field name returns the entire form values object.
const values = watch();
This is convenient but expensive because it subscribes to every field. In a large form, every keystroke can rerender the component that called it.
Avoid using whole-form watch for:
- Live previews of large forms.
- Autosave of every field.
- Debug panels left in production.
- Form-level derived data that only needs a few fields.
- Expensive calculations on every input change.
Prefer watching specific fields:
const [country, postalCode] = watch(["country", "postalCode"]);
Or isolate the subscription:
function ShippingPreview() {
const { control } = useFormContext<CheckoutValues>();
const country = useWatch({ control, name: "shipping.country" });
const postalCode = useWatch({ control, name: "shipping.postalCode" });
return <ShippingEstimate country={country} postalCode={postalCode} />;
}
useWatch Props
Common useWatch options include:
control: the form control object fromuseForm.name: one field name or an array of field names.defaultValue: value used before the watched value is available.disabled: disables the subscription.exact: controls exact name matching.compute: derives a smaller value from the watched value.
Example with one field:
const plan = useWatch({
control,
name: "plan",
defaultValue: "free",
});
Example with multiple fields:
const [firstName, lastName] = useWatch({
control,
name: ["firstName", "lastName"],
});
Example with compute:
const hasBusinessPlan = useWatch({
control,
compute: (values: SignupValues) => values.plan === "business",
});
compute is useful when the component only needs a derived value. It can reduce rerenders when the derived result does not change.
useFormState
useWatch isolates value subscriptions. useFormState isolates form-state subscriptions.
function SaveBar() {
const { control } = useFormContext<ProfileValues>();
const { isDirty, isSubmitting, errors } = useFormState({ control });
return (
<footer>
<button disabled={!isDirty || isSubmitting}>Save</button>
{Object.keys(errors).length > 0 ? <span>Fix validation errors</span> : null}
</footer>
);
}
Use useFormState when a component only needs errors, dirty state, touched state, or submit state. Do not make the root form component subscribe to all form state if only one toolbar or field component needs it.
useController and Render Isolation
useController combines a field value subscription and field state subscription for controlled components. It uses useWatch and useFormState internally to isolate rerenders around one controlled field.
function ControlledTextField({ name }: { name: FieldPath<FormValues> }) {
const { field, fieldState } = useController<FormValues>({ name });
return (
<>
<TextField
value={field.value ?? ""}
onChange={field.onChange}
onBlur={field.onBlur}
inputRef={field.ref}
/>
{fieldState.error ? <p role="alert">{fieldState.error.message}</p> : null}
</>
);
}
This is helpful for UI libraries, but native inputs should usually use register instead of being forced into controlled components.
getValues for One-Time Reads
If you need the current value inside an event handler but do not need rendering to update when it changes, use getValues.
function EstimateButton() {
const { getValues } = useFormContext<CheckoutValues>();
function handleEstimate() {
const values = getValues(["shipping.country", "shipping.postalCode"]);
calculateShipping(values);
}
return <button type="button" onClick={handleEstimate}>Estimate</button>;
}
getValues reads current values without subscribing the component to future changes. This is useful for buttons, submit handlers, validation helpers, and one-time calculations.
subscribe for Side Effects Without Rendering
React Hook Form also supports subscriptions for observing form changes without forcing a React render.
This can be useful for:
- Analytics.
- Logging.
- Imperative integrations.
- Autosave pipelines.
- Syncing to external systems.
Example shape:
useEffect(() => {
const unsubscribe = subscribe({
formState: { values: true },
callback: ({ values }) => {
queueAutosave(values);
},
});
return unsubscribe;
}, [subscribe]);
Do not use render subscriptions when the UI does not need to render.
Large-Form Performance Principles
Large forms perform well when subscriptions are narrow.
Good patterns:
- Use
registerfor native fields. - Use
Controlleronly for controlled components. - Split sections into components.
- Use
useWatchinside the section that needs the value. - Use
useFormStatenear the component that needs form state. - Use
getValuesfor event-time reads. - Use
subscribefor side effects that do not render. - Avoid global form previews unless they are memoized and scoped.
- Avoid storing all form values in React state.
The goal is not to prevent all rerenders. The goal is to rerender the smallest useful part of the tree.
Dynamic Sections
Conditional sections are a common use case.
Less scalable:
function LargeForm() {
const methods = useForm<FormValues>();
const accountType = methods.watch("accountType");
return (
<FormProvider {...methods}>
<BasicFields />
{accountType === "business" ? <BusinessFields /> : null}
<BillingFields />
<SecurityFields />
</FormProvider>
);
}
Better isolation:
function LargeForm() {
const methods = useForm<FormValues>();
return (
<FormProvider {...methods}>
<BasicFields />
<BusinessFieldsGate />
<BillingFields />
<SecurityFields />
</FormProvider>
);
}
function BusinessFieldsGate() {
const { control } = useFormContext<FormValues>();
const accountType = useWatch({ control, name: "accountType" });
return accountType === "business" ? <BusinessFields /> : null;
}
Now BusinessFieldsGate handles the conditional render without making LargeForm rerender on account type changes.
Field Arrays
Field arrays can become expensive because each row may contain many fields and derived UI.
Recommendations:
- Keep each row as a separate component.
- Use stable keys from
useFieldArray. - Watch row-specific values inside the row.
- Avoid watching the entire array at the parent.
- Avoid rebuilding all rows when one row changes.
Example:
function LineItemRow({ index }: { index: number }) {
const { control, register } = useFormContext<OrderForm>();
const quantity = useWatch({
control,
name: `items.${index}.quantity`,
});
const price = useWatch({
control,
name: `items.${index}.price`,
});
return (
<tr>
<td><input {...register(`items.${index}.name`)} /></td>
<td><input type="number" {...register(`items.${index}.quantity`)} /></td>
<td>{Number(quantity || 0) * Number(price || 0)}</td>
</tr>
);
}
For very large arrays, consider virtualization, pagination, or splitting editing into smaller experiences.
Derived Values
Derived values should be scoped and cheap.
Example:
function OrderTotal() {
const { control } = useFormContext<OrderForm>();
const items = useWatch({ control, name: "items" });
const total = useMemo(
() =>
items.reduce(
(sum, item) => sum + Number(item.quantity || 0) * Number(item.price || 0),
0,
),
[items],
);
return <strong>Total: {total}</strong>;
}
If the form is large, avoid deriving everything from watch() at the root. Put the derived output where it belongs and subscribe only to the fields it needs.
Validation and Error Rendering
Validation errors can also cause broad rerenders if handled at the wrong level.
Less scalable:
const {
formState: { errors },
} = useForm<FormValues>();
If the root component reads errors, it may rerender when any field error changes.
Better:
function FieldError({ name }: { name: FieldPath<FormValues> }) {
const { control } = useFormContext<FormValues>();
const { errors } = useFormState({ control, name, exact: true });
const error = get(errors, name);
return error ? <p role="alert">{error.message}</p> : null;
}
In practice, many teams wrap this pattern inside reusable field components.
FormProvider and Context
FormProvider lets child components access form methods with useFormContext.
<FormProvider {...methods}>
<ProfileFields />
</FormProvider>
Context access is not automatically slow. The important question is what the child reads and subscribes to. A child that only calls register is different from a child that watches the entire form.
Good practice:
- Use
FormProviderto avoid prop drilling. - Keep subscriptions inside small components.
- Avoid passing frequently changing derived values through many layers.
- Avoid reading broad
formStatein layout components.
Default Values and First Render
Watched fields need default values for predictable first render behavior.
const methods = useForm<FormValues>({
defaultValues: {
plan: "free",
seats: 1,
},
});
Without defaults, a watched value may be undefined on first render. That can cause flicker or conditional UI to mount incorrectly.
Use:
defaultValuesatuseForm.defaultValueinuseWatchfor local fallback.resetwhen server data loads.
Profiling Large Forms
Performance work should be measured.
Useful signals:
- Typing feels delayed.
- React DevTools Profiler shows large rerenders per keystroke.
- Expensive components rerender when unrelated fields change.
- Whole-form previews recalculate on every input.
- Controlled UI library fields lag.
- Field arrays rerender every row after one edit.
Fix the subscription scope before reaching for broad memoization. memo helps only when props are stable and the component does not subscribe to changing context or form state.
Common Mistakes
Common mistakes include:
- Calling
watch()for the whole form in the root component. - Watching a field at the root when only a child section needs it.
- Using
useWatchwithout default values and getting first-render flicker. - Reading broad
formStatein a layout component. - Using
Controllerfor every native input. - Storing every form field in external state.
- Watching a whole field array when only one row needs values.
- Using
watchfor side effects instead of a subscription or effect with care. - Adding
memowhile keeping broad subscriptions.
Best Practices
Best practices include:
- Use
watchfor small, local conditional rendering. - Use
useWatchfor isolated child subscriptions. - Use
useFormStatefor isolated error, dirty, and submit state. - Use
getValuesfor one-time reads. - Use subscriptions for non-rendering side effects.
- Keep conditional sections close to their watched fields.
- Provide complete
defaultValues. - Prefer uncontrolled native inputs with
register. - Use
Controlleronly for controlled components. - Profile before and after optimization.