
- Read
- DOM refs,
getValues() - Keystroke
- 0 re-renders [6]
- Unmount
shouldUnregister:false
Every cell in this sheet falls out of one answer. React Hook Form keeps values in a mutable ref. TanStack Form keeps them in an external store. Conform reads them out of the DOM. Everything else — arrays, conditionals, wizards, the render budget — is downstream of that.
Read this section once and the matrix below stops needing explanation. The three libraries do not differ in taste; they differ in where the bytes sit when a field unmounts.
A field unmounts. What happens to its value?
RHF: kept — hidden fields still submit. TanStack: kept, and so is its validation error, which then silently blocks the submit. Conform: gone — the input left the DOM, so the value left with it. Three defaults, three different bugs, no correct one.

getValues()shouldUnregister:false
form.Subscribe, useSelectorhandleSubmit with nothing on screen to fix. [18]
FormData on submit / intent<PreserveBoundary>. [23]Five patterns that break naive implementations, against the three storage models. Each cell carries the actual behaviour and, where one exists, the ticket number.
The invariant nobody gets right first time: rows must be keyed by identity, not index.
useFieldArray mints a per-row id. The docs are blunt: key={field.id} correct, key={index} incorrect. Full mutator set — append insert swap move replace remove. [1]
Nested parent ops propagate to child arrays automatically. [2] Sharp edges: append({}) is invalid (pass every default), two hooks may not share a name, and never stack append(); remove(0); in one handler.
move = only the last array reorders. Keep a list of callbacks. [32]
No row identity at all. The official arrays guide keys by index:
<form.Field key={i} name={`people[${i}].name`}>
Which is the direct cause of the duplication bug. If you reorder, carry your own id on each row and key on that. [9]
Stable field.key from metadata [19], and mutations are submit-button intents (form.insert / remove / reorder) — so the array editor works with JavaScript off. [20]
The inverse of RHF's trap: rows are re-mounted by key on every intent, which is what makes reorder correct — and what resets any controlled widget in the row.
A Select/Combobox viauseInputControl loses its value on every insert/remove/reorder [28]
The single biggest behavioural fork — and it is not a preference. It falls straight out of §01.
Default shouldUnregister: false → hidden fields still submit. Per-field fix: register(name, { shouldUnregister: true }). [3]
Dynamic validation is the awkward half: either swap the resolver (re-validates everything) or write a superRefine that reads sibling values.
useFieldArray — see the collision below [1]
Worse than a ghost value: the field's meta and errors survive too. Unmount a field while it's invalid and handleSubmit is blocked by an error attached to a field that no longer exists.
Escape hatch is manual: form.deleteField(name). [17]
Opposite failure: unmounted means deleted. Wrap the branch in <PreserveBoundary name="step-1"> from @conform-to/react/future. [23]
Conditional validation is cleanest here: the schema is rebuilt per submission-intent, so z.discriminatedUnion on a "type" field just works, with no client state to sync. [21]
"Username taken" is where naive code dies: debounce → cancel in-flight → discard stale → per-field revalidate → a non-flickering isValidating.
Sync deps: register("confirm", { deps: "password" }) — but the docs note it is "only limited to register api not trigger", so a wizard's Next button calling trigger() will not fan out to dependents. [3]
No built-in debounce. Community consensus after several rounds of pain: keep the remote check out of the Zod resolver.
useWatch(field) + debounce + AbortController
→ setError / clearErrors
Putting it in an async resolver is what produces stale errors and a stuck isValidating [8]
The only one with the whole lifecycle built in: asyncDebounceMs={500} per field, sync validators gate the async one so you don't hit the network on obviously-invalid input, and asyncAlways overrides that. [10]
Cross-field is declarative and symmetric: onChangeListenTo: ['password']. [11]
No primitive needed — the whole schema re-runs on each validation intent, so cross-field is free. [22]
Deliberately no client async validation; the schema is a function of the intent. Untargeted fields emit VALIDATION_SKIPPED (reuse the last result); a check the client can't run emits VALIDATION_UNDEFINED, deferring to the server, where the same schema runs with { async: true }. [21]
One schema. No uniqueness endpoint. No client/server drift. Worth stealing even if you don't use Conform.
The question is only ever "where does step state live" — and §01 already answered it.
The official recipe is bring a state library: one useForm per route plus little-state-machine as the cross-step source of truth. [6]
The common variant in practice: one FormProvider, a per-step schema, and gate Next on await trigger(stepFields) — leaning on shouldUnregister:false to hold values for unmounted steps. [34]
trigger() ignores deps, so cross-step dependent rules need an explicit re-trigger [3]
The only first-class primitive of the three. FormGroup / withFieldGroup give each step its own validators and an onGroupSubmit, while values stay in the one parent store: advance when the group validates, call form.handleSubmit() only at the end. [13]
The wizard is a server concern. Steps are conditionally rendered fieldsets of one form — and because values live in the DOM they vanish on step change, so each step's fields go inside a named PreserveBoundary (shipped v1.17.0). [24]
Per-step validation is the validate intent scoped to that step's field names. [20] Refresh-persistence is whatever your framework's session gives you — which is the point of the library.
No public head-to-head render-count benchmark exists for these three in 2026 [25]. What is measured is below.
Uncontrolled by construction: nothing re-renders on keystroke. The failure mode is subscription placement, not the library. The maintainer's answer to the canonical 100+-field lag report:
"Changing a field updates different form states
like dirtyFields… which would cause your whole
form to re-render due to that update subscription
you made in the parent component"
Rules that follow: read formState in a leaf via useFormState, never the root; prefer register over Controller (every controlled wrapper is a render per keystroke by construction); subscribe() when you need values but not a render [5]; memo() the rows, because FormProvider re-renders its subtree. [7]
defaultValues to restore on scroll-back [6]
Selector subscriptions should make scoping automatic. Two 2026 caveats say otherwise.
form.Subscribe re-renders on every form mutation whenever the selector returns an object or array — useStore compares with Object.is and the selector allocates a fresh reference each render. Return primitives, or pass shallow.
Hygiene: always pass a selector, prefer form.Subscribe over useSelector for UI, and note useStore is deprecated. [12]
Barely participates. The DOM holds the values; React state holds only the last validation result, so keystroke re-render cost is near zero. [22]
The cost moves elsewhere: every validation is potentially a server round-trip, and huge repeating groups are expensive on the parse side.
v1.19.4 patched a DoS inparseSubmission triggered by "submissions with numerous repeated fields" [24]
"Field array relies on inputs being mounted and unmounted to manage its internal state — enabling shouldUnregister causes newly added fields to be unregistered on re-render, so their values are lost." [1]
shouldUnregister: true is the sanctioned cure for ghost fields. It is explicitly unsupported inside useFieldArray, and register's own docs repeat the warning. [3] So a repeating group whose rows contain conditional sub-fields cannot use the global switch at all. Your two remaining options: strip the dead branches in a handleSubmit transform, or model the row as a discriminated union and let Zod's .transform() drop them.
Every sharp edge above, in one list. "Design" means it is working as intended and will still cut you.
[1,2,3,4] inside a conditionally-rendered form yields [1,3,4,4].form.Subscribe over-renders. Object/array-returning selectors compare with Object.is against a fresh reference → a render on every form mutation. Open since Mar 2026; pass shallow.<form.Field> take 10+ seconds and can crash the tab (v1.29.1, May 2026) — the exact shape of a large dynamic form.form.deleteField is the manual escape hatch.shouldUnregister × useFieldArray are incompatible. The cure for ghost fields is the poison for field arrays. Documented, unfixed, no workaround inside the library.useRef plus a formState proxy whose getters register subscriptions as a render side-effect: memoized reads never invalidate, watch() goes stale, errors stops updating. v7 workaround is 'use no memo' on every form component.move callback and each new nested array overwrites it. Keep a list of callbacks.parseSubmission triggered by submissions with numerous repeated fields.The comparison articles are qualitative and TanStack's own table is a feature matrix, not a benchmark [26]. These four numbers are the ones with a source behind them.
~14 kB with Zod.
~11 kB with Zod. Smallest core of the two measured.
Absent from that benchmark.
Uninstrumented field.
*Zero outside subscribers — unless a selector returns an object, and then it's every mutation. [14]
The one row TanStack's own table scores RHF ❓ on — honestly.
The 2026 headline. Turning the compiler on across a large RHF v7 codebase is a migration, not a flag.
v8's headline feature is exactly this: "V8 adds first-class support for the React Compiler. No additional configuration is required." [4]
No interior mutability in render.
Pick by the hard pattern you actually have, not by the library you already like.
The only one that hands you a stable row identity, and swap/move propagate correctly through nested arrays. With TanStack, carry your own row id. [1]
Every default is wrong for someone: RHF and TanStack submit ghosts, Conform loses answers. Make the payload contract explicit — and remember the RHF fix is unavailable inside field arrays.
TanStack if you want it declarative and debounced [10]; Conform if you'd rather have one schema and no uniqueness endpoint [21]; RHF only with the remote check kept outside the resolver. [8]
FormGroup is the only first-class wizard primitive of the three. RHF's own docs tell you to bring a state library. [13]
With useFormState pushed to the leaves. Benchmark TanStack's field mount cost against your field count before committing. [15]
None of the three writes to localStorage. That's your subscribe() (RHF, renderless [5]) or a store subscription (TanStack), plus a debounced write.