DEV Community

Nainik Mehta
Nainik Mehta

Posted on

Uncontrolled-First React Forms: React Hook Form + Zod

Why React Hook Form performance matters for large enterprise forms

Forms that feel snappy are a deceptively large part of developer experience and user satisfaction. At scale — 20+ fields, nested sections, dynamic line items — the common controlled-input pattern (setState per keystroke) quickly accumulates re-renders and visible input-to-screen latency.

React Hook Form (RHF) is designed uncontrolled-first: it keeps values in refs and the DOM and only updates React state when you actually need it. Combined with a fast validation resolver like Zod, you can reduce render counts and shave keystroke latency dramatically. In one production benchmark I ran, switching to uncontrolled-first with RHF + Zod reduced keystroke latency from ~80ms to <10ms and cut render count per keystroke from 6 → 1.

This article distills the exact checklist and examples I used in production so you can reproduce the same React Hook Form performance wins.

The principle: where you read is what re-renders

RHF's whole performance model is subscription-based: the component that reads a piece of form state is the one that will re-render when that state changes. The most common mistake is calling watch() at the top-level form component — that subscribes the entire form and causes it to re-render on every keystroke.

Instead:

  • Use register for native inputs (uncontrolled-first).
  • Use Controller/useController only for third-party controlled widgets.
  • Push subscriptions down to the smallest component that actually needs live values (useWatch/useFormState).

Production checklist (applies to large, real-world forms)

1) Prefer uncontrolled-first registration

  • Use register for plain inputs. Only use Controller when interacting with a third-party controlled component.

2) Validate strategically

  • Use zodResolver for a single source of truth, but validate on submit or blur (mode: 'onSubmit' or 'onTouched'), not on every keystroke.

3) Avoid global watch

  • Don’t watch() the entire form. Use useFormState for form-level flags (isSubmitting/isValid) inside a child, and use useWatch for specific fields at the UI leaf.

4) Minimize setValue/useController calls

  • Batch programmatic updates and avoid programmatic writes on every input event.

5) Profile renders

  • Use the React DevTools Profiler and "Highlight updates" overlay to confirm render-count wins after changes.

Concrete starter example

Below is the minimal wiring I used for the benchmark form (20+ fields, nested sections). The key choices are: register native inputs, zodResolver, and mode: 'onSubmit' (validate on submit).

import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'

const schema = z.object({
  email: z.string().email(),
  // ... other fields
})

export function MyForm() {
  const { register, handleSubmit, control } = useForm({
    resolver: zodResolver(schema),
    mode: 'onSubmit', // or 'onTouched'
    defaultValues: { email: '', /* ... */ },
  })

  return (
    <form onSubmit={handleSubmit(data => console.log(data))}>
      <input type="email" {...register('email')} />
      {/* many other registered inputs */}
      <button type="submit">Submit</button>
    </form>
  )
}
Enter fullscreen mode Exit fullscreen mode

This pattern keeps typing in extremely cheap — RHF updates internal refs and the DOM but does not re-render the whole form for each character.

Using controlled third-party components correctly

When you must use a controlled component (React-Select, Material UI TextField, datepickers), wrap only that field in a Controller. That converts one field to a controlled pattern — it will re-render while typing, but only for that field.

import { Controller } from 'react-hook-form'

<Controller
  name="country"
  control={control}
  render={({ field }) => (
    <ReactSelect {...field} options={countryOptions} />
  )}
/>
Enter fullscreen mode Exit fullscreen mode

The idea: pay the controlled re-render cost exactly where the widget requires it, and nowhere else.

Practical tips on validation timing

Validation is not free. If you set mode: 'onChange' or run a resolver on every keystroke, you force re-validation (and potentially re-renders) on each input. Instead:

  • Default to onSubmit or onTouched for large forms.
  • If a field needs immediate feedback (password strength, format hint), trigger validation only for that field with trigger('password') from an onChange callback — not the whole form.
  • Use zodResolver to centralize rules and to reuse the same schema on the server.

Avoiding the biggest anti-patterns

  • Don't call watch() with no args at the top-level form — it subscribes to all fields and explodes renders.
  • Don’t destructure formState in a parent and use properties like isValid there. Instead, move the submit button into a child and use useFormState({ control }) so only the button subscribes.
  • Don't recreate defaultValues every render; keep them stable (useMemo or constants).

How I measured the gains

  • Render counts: React DevTools Profiler to confirm which components re-render on typing. After the changes, only the active field should flash.
  • Keystroke latency: measure perceived input latency by logging timestamps in input handlers and comparing to paint (or using a high-precision performance trace). In my benchmark form (20+ fields, nested sections):
    • Keystroke latency: ~80ms → <10ms
    • Render count per keystroke: 6 → 1
    • User-perceived snappiness: immediate

Those numbers are conservative for real-world UIs with heavy child components and conditional sections — small wiring changes compound across dozens of fields.

Final notes and trade-offs

Uncontrolled-first RHF with Zod is a practical, measurable win for enterprise forms. The trade-offs are:

  • You need to be intentional about where you subscribe (useWatch/useFormState).
  • Controlled widgets still cost re-renders (but constrained to their subtree).
  • Adding a resolver like Zod brings bundle size; reserve schema resolvers for forms that share validation with the server or need complex rules.

If your forms feel sluggish at scale, this uncontrolled-first pattern is the single-most effective change I made in production. Start with the checklist, profile, and push subscriptions down — the performance wins are immediate and repeatable.

What bottleneck did you run into in your forms, and how did you fix it? Share a profiler snapshot or a code snippet — small details often hide big gains.

Top comments (0)