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.
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
-
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.
-
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.
-
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
watchonly where you genuinely need a live derived value. -
Pick a reducer for logic. For logic-heavy forms, a
useReducergives one deterministic transition function, derived fields for free, and serializable state you can unit-test without rendering. Recover keystroke cost with selector subscriptions. -
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 — the reducer-driven hook this comparison measures against
- Custom useFormField Hook Performance Tuning — selector subscriptions that close the reducer’s keystroke gap
- Controlled vs Uncontrolled Forms — the axis these two models diverge on