DEV_NET_CORE
GET_STARTED
ReactForms, validation, and frontend performance in production

`watch` vs `useWatch`, form render isolation, and large-form performance

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 Controller or useController.

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.

Code
const { register, watch } = useForm<FormValues>();

const plan = watch("plan");

Common forms:

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

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

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

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

Code
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:

Code
const [country, postalCode] = watch(["country", "postalCode"]);

Or isolate the subscription:

Code
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 from useForm.
  • 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:

Code
const plan = useWatch({
  control,
  name: "plan",
  defaultValue: "free",
});

Example with multiple fields:

Code
const [firstName, lastName] = useWatch({
  control,
  name: ["firstName", "lastName"],
});

Example with compute:

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

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

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

Code
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:

Code
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 register for native fields.
  • Use Controller only for controlled components.
  • Split sections into components.
  • Use useWatch inside the section that needs the value.
  • Use useFormState near the component that needs form state.
  • Use getValues for event-time reads.
  • Use subscribe for 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:

Code
function LargeForm() {
  const methods = useForm<FormValues>();
  const accountType = methods.watch("accountType");

  return (
    <FormProvider {...methods}>
      <BasicFields />
      {accountType === "business" ? <BusinessFields /> : null}
      <BillingFields />
      <SecurityFields />
    </FormProvider>
  );
}

Better isolation:

Code
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:

Code
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:

Code
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:

Code
const {
  formState: { errors },
} = useForm<FormValues>();

If the root component reads errors, it may rerender when any field error changes.

Better:

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

Code
<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 FormProvider to avoid prop drilling.
  • Keep subscriptions inside small components.
  • Avoid passing frequently changing derived values through many layers.
  • Avoid reading broad formState in layout components.

Default Values and First Render

Watched fields need default values for predictable first render behavior.

Code
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:

  • defaultValues at useForm.
  • defaultValue in useWatch for local fallback.
  • reset when 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 useWatch without default values and getting first-render flicker.
  • Reading broad formState in a layout component.
  • Using Controller for every native input.
  • Storing every form field in external state.
  • Watching a whole field array when only one row needs values.
  • Using watch for side effects instead of a subscription or effect with care.
  • Adding memo while keeping broad subscriptions.

Best Practices

Best practices include:

  • Use watch for small, local conditional rendering.
  • Use useWatch for isolated child subscriptions.
  • Use useFormState for isolated error, dirty, and submit state.
  • Use getValues for 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 Controller only for controlled components.
  • Profile before and after optimization.

Interview Practice

PreviousSecure cookie flags: `HttpOnly`, `Secure`, `SameSite`, path, domain, and expirationNext UpBuilt-in validation, async validation, and schema resolvers with Yup or Zod