The exact problem: a controlled useReducer form re-renders the entire tree on every keystroke, and you need to decide whether React Hook Form’s uncontrolled subscription model is worth adopting — or whether the reducer’s explicit state is worth keeping.

Context and Prerequisites

This comparison sits under React form hook architecture, which specifies the reducer-driven hook in full. The question here is architectural: React Hook Form and a hand-rolled reducer make opposite bets about where field values live, and that single decision drives their re-render profiles. Understanding controlled vs uncontrolled forms is the prerequisite, because it is exactly the axis these two libraries diverge on.

The Core Tradeoff

React Hook Form is uncontrolled by default: field values live in the DOM, read through register and refs, and React state is not touched on each keystroke. A custom useReducer form is controlled: every keystroke dispatches an action, produces a new state object, and re-renders every consumer of that state. The diagram contrasts what happens on a single keystroke in each model.

React Hook Form versus custom reducer re-render propagation On a keystroke, React Hook Form updates the DOM input directly and notifies only the subscribed field, leaving sibling fields unrendered. A controlled useReducer dispatches an action, creates a new state object, and re-renders every field that reads the state unless selectors are added. React Hook Form (uncontrolled) useReducer (controlled) keystroke DOM ref no React state 1 field renders siblings idle subscribe(name) keystroke dispatch() new state object N fields render unless selectors state changes

Re-Render Counts Compared

The table below is the empirical shape of the tradeoff. Numbers are renders triggered per action on a 60-field form; measure your own with the DevTools Profiler before deciding.

Interaction React Hook Form Controlled useReducer (naive) useReducer + selectors
Single keystroke 0 form renders; 1 field if watched 1 form render + all field renders 1 field render
Blur / touch a field 1 subscribed field full tree 1 field
Cross-field derived value needs watch, re-renders watchers free — reducer computes it free — reducer computes it
Submit 1 render 1 render 1 render
Programmatic reset 1 render 1 render affected fields

The pattern is clear. React Hook Form wins raw input throughput because it does not route keystrokes through React state at all. A naive controlled reducer loses badly on typing but wins on derived state, because the reducer computes dependent values in one deterministic pass. Adding selector subscriptions — the technique in custom useFormField hook performance tuning — closes most of the keystroke gap while keeping the reducer’s explicit-state advantage.

Minimal Implementations Side by Side

// React Hook Form: values live in the DOM; the form does not render on keystroke.
import { useForm } from 'react-hook-form';

function RhfForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<{ email: string }>();
  return (
    <form onSubmit={handleSubmit(v => console.log(v))}>
      {/* register wires an uncontrolled ref — no value/onChange round-trip. */}
      <input {...register('email', { required: 'Required' })} aria-invalid={!!errors.email} />
      {errors.email && <p role="alert">{errors.email.message}</p>}
    </form>
  );
}
// Controlled useReducer: every keystroke dispatches; derived state is trivial.
import { useReducer } from 'react';

type State = { price: number; qty: number; total: number };
type Action = { type: 'set'; key: 'price' | 'qty'; value: number };

function reducer(s: State, a: Action): State {
  const next = { ...s, [a.key]: a.value };
  // Derived field computed in the same transition — no extra render, no watch().
  next.total = next.price * next.qty;
  return next;
}

function ReducerForm() {
  const [state, dispatch] = useReducer(reducer, { price: 0, qty: 1, total: 0 });
  return (
    <form>
      <input type="number" value={state.qty}
        onChange={e => dispatch({ type: 'set', key: 'qty', value: +e.target.value })} />
      <output>{state.total}</output>
    </form>
  );
}

Step-by-Step: Choosing Between Them

  1. Profile first. Open the React DevTools Profiler and record a few keystrokes. If the whole form flashes on every character, you have a controlled-state storm. Quantify it before optimizing.

  2. Classify the form. Is it input-heavy (a long data-entry form, mostly independent fields) or logic-heavy (interdependent fields, conditional steps, a state machine)? The classification, not a benchmark alone, points to the model.

  3. Pick uncontrolled for throughput. For input-heavy forms, React Hook Form’s subscription model gives near-zero render cost per keystroke with minimal code. Reach for watch only where you genuinely need a live derived value.

  4. Pick a reducer for logic. For logic-heavy forms, a useReducer gives one deterministic transition function, derived fields for free, and serializable state you can unit-test without rendering. Recover keystroke cost with selector subscriptions.

  5. Plan migration through a shared schema. Keep one validation schema — see integrating Zod for schema validation — so behaviour is stable while you move fields between models one section at a time.

Failure Modes and Edge Cases

1. Overusing watch turns React Hook Form controlled again

watch() subscribes the calling component to field changes and re-renders it on each keystroke. Watching many fields at a high level recreates the very storm you adopted the library to avoid.

// Prefer useWatch scoped to a child, not a top-level watch of everything.
const total = useWatch({ control, name: 'total' }); // re-renders only this subtree

2. Controller wraps every field and erases the benefit

Wrapping controlled component libraries in Controller reintroduces React state per field. Use it only for inputs that truly cannot be uncontrolled, not as the default.

3. Naive reducer with no selectors on a large form

A single useContext(state) at the leaf makes every field a full-state subscriber. Add a selector layer before concluding the reducer approach is too slow.

4. Reset semantics differ between models

React Hook Form’s reset() rewrites the DOM refs; a reducer’s reset dispatches an action producing a new state. Migrating between them silently changes what “dirty after reset” means — re-verify your dirty tracking, covered in dirty and pristine state tracking.

Verification Checklist

FAQ

Why does React Hook Form re-render less than my useReducer form?

React Hook Form keeps field values in the DOM through uncontrolled refs and register, not in React state. A keystroke updates the input directly and notifies only subscribers of that field, so the form component does not re-render on every character. A controlled useReducer dispatches on each keystroke, producing a new state object and re-rendering every component that reads it — unless you add selector-based memoization that lets each field subscribe to only its own slice. The difference is architectural, not a matter of one library being “faster.”

When is a custom useReducer form actually the better choice?

When the form is logic-heavy rather than input-heavy: many fields whose values derive from other fields, a wizard whose steps unlock conditionally, or state that a state machine must own explicitly. A reducer gives you one deterministic transition function and a serializable state you can test in isolation without rendering. The re-render cost is real but bounded, and selector subscriptions recover most of it. If your form is mostly independent inputs with little cross-field logic, the reducer’s advantages do not apply and React Hook Form is the simpler win.

Can I migrate from a useReducer form to React Hook Form incrementally?

Yes, if both share one validation schema. Keep the schema as the source of truth, then move fields to register one section at a time, wrapping any remaining controlled widgets with Controller. The shared schema means validation behaviour does not change during the migration, so you can move field by field and diff re-render counts in the Profiler as you go. Migrate the input-heavy sections first, since those show the largest render reduction, and leave interdependent sections for last or keep them on the reducer permanently.


Related

React Form Hook Architecture