← Default view
Failure-mode analysis · 5 hard patterns × 3 libraries

Where do the
field values live?

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.

SheetFMEA · rev 2026-07
SubjectReact forms, hard parts
Units under testRHF 7.81 · TSF 1.30 · Conform 1.19
Citations34
Open defects6
HOLDS — library gives you the primitive WORKAROUND — works, but you write the missing part DEFECT — known open bug or a design collision
01

The storage model

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.

React Hook Form
React Hook Form ⭐ 44.8k
v7.81.0 · Jul 2026 · v8 beta in flight [29]
useRef{}
values live outside React
Read
DOM refs, getValues()
Keystroke
0 re-renders [6]
Unmount
shouldUnregister:false
Value kept. "isDeveloper is false but favoriteLanguage is still in the payload." Ghost fields ship to your API. [3]
TanStack Form
TanStack Form ⭐ 6.6k
v1.30.x · Jul 2026 [30]
store()
external store + selectors
Read
form.Subscribe, useSelector
Keystroke
0 — unless a selector returns an object [14]
Unmount
value + meta + errors retained
Value AND stale error kept. A field that unmounts while invalid leaves its error in the store and blocks handleSubmit with nothing on screen to fix. [18]
Conform
Conform ⭐ 2.6k
v1.19.4 · Jun 2026 [31]
the DOM
the form element is the store
Read
FormData on submit / intent
Keystroke
0 — React holds only the last result [22]
Unmount
value deleted
Value dropped. The inverse failure: go back to step 1 of a wizard and step 1's answers are gone. Fix is <PreserveBoundary>. [23]
02

The failure matrix

Five patterns that break naive implementations, against the three storage models. Each cell carries the actual behaviour and, where one exists, the ticket number.

PATTERN / UNIT
React Hook Form
TanStack Form
Conform
R1

Field arrays & reorder

The invariant nobody gets right first time: rows must be keyed by identity, not index.

React Hook Form Holds

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.

DnD over nested arrays: one ref for move = only the last array reorders. Keep a list of callbacks. [32]
TanStack Form Defect

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]

#957 — remove item 2 from [1,2,3,4] in a conditionally-rendered form → [1,3,4,4]
Conform Workaround

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 via useInputControl loses its value on every insert/remove/reorder [28]
R2

Conditional fields on unmount

The single biggest behavioural fork — and it is not a preference. It falls straight out of §01.

React Hook Form Workaround

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.

The fix is unusable inside useFieldArray — see the collision below [1]
TanStack Form Defect

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]

#1133 — stale error from an unmounted field blocks submission
Conform Workaround

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]

R3

Cross-field & dependent async

"Username taken" is where naive code dies: debounce → cancel in-flight → discard stale → per-field revalidate → a non-flickering isValidating.

React Hook Form Workaround

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]
TanStack Form Holds

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]

Documented footgun: A↔B mutual listening loops forever [11]
Conform Holds

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.

R4

Multi-step wizards

The question is only ever "where does step state live" — and §01 already answered it.

React Hook Form Workaround

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]

Interacts with R3: trigger() ignores deps, so cross-step dependent rules need an explicit re-trigger [3]
TanStack Form Holds

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]

…with the R2 caveat: an abandoned branch's stale errors stay in the store [18]
Conform Workaround

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.

R5

The 100+ field render budget

No public head-to-head render-count benchmark exists for these three in 2026 [25]. What is measured is below.

React Hook Form Holds

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]

Past ~1000 rows, virtualize — then windowed rows unmount, so you need form-level defaultValues to restore on scroll-back [6]
TanStack Form Defect

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.

#2090 — open since Mar 2026 #2150 — mount/unmount is worse than O(N): ~1300 fields = 10+ s, sometimes crashes the tab, vs ~200 ms for plain inputs (v1.29.1)

Hygiene: always pass a selector, prefer form.Subscribe over useSelector for UI, and note useStore is deprecated. [12]

Conform Holds

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 in parseSubmission triggered by "submissions with numerous repeated fields" [24]
Design collision · no ticket, no fix

In RHF, the fix for R2 is forbidden inside R1

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

03

Defect register

Every sharp edge above, in one list. "Design" means it is working as intended and will still cut you.

ID
Unit
Effect
Status
TanStack
Index-keyed rows duplicate on remove. Removing item 2 from [1,2,3,4] inside a conditionally-rendered form yields [1,3,4,4].
◉ Open
TanStack
Stale error blocks submit. A field that unmounts while invalid keeps its error in the store; submission is blocked by a field nobody can see.
◉ Open
TanStack
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.
◉ Open
TanStack
Field mount cost is super-linear. ~1300 <form.Field> take 10+ seconds and can crash the tab (v1.29.1, May 2026) — the exact shape of a large dynamic form.
◉ Open
TanStack
Unmounted fields leak value + meta + fieldInfo into the store. form.deleteField is the manual escape hatch.
◈ By design
RHF
shouldUnregister × useFieldArray are incompatible. The cure for ghost fields is the poison for field arrays. Documented, unfixed, no workaround inside the library.
◈ By design
RHF
React Compiler incompatibility. A mutated 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.
✓ Fixed in v8 beta
RHF
Nested-array drag & drop reorders only the last array, because a single ref holds the move callback and each new nested array overwrites it. Keep a list of callbacks.
◈ Pattern trap
Conform
Controlled widgets reset on every list intent. Rows re-mount by key on insert/remove/reorder — correct for native inputs, destructive for a Select/Combobox in a sortable list.
◈ By design
Conform
DoS in parseSubmission triggered by submissions with numerous repeated fields.
✓ Patched 1.19.4
04

What is actually measured

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.

AXIS
React Hook Form 7.81
TanStack Form 1.30
Conform 1.19
A1

Bundle, min+gzip

esbuild, May 2026 [25]

RHF~9 kB

~14 kB with Zod.

TanStack~6 kB

~11 kB with Zod. Smallest core of the two measured.

ConformNot measured

Absent from that benchmark.

A2

Re-renders / keystroke

Uninstrumented field.

RHF0

Ref-based. [6]

TanStack0*

*Zero outside subscribers — unless a selector returns an object, and then it's every mutation. [14]

Conform0

DOM-backed. [22]

A3

Built-in async debounce

The one row TanStack's own table scores RHF ❓ on — honestly.

RHFNone

Bring your own debounce + AbortController. [26]

TanStackasyncDebounceMs

Per field or per validator. [10]

Conformn/a by design

Server round-trip instead of a client endpoint. [22]

A4

React Compiler safe

The 2026 headline. Turning the compiler on across a large RHF v7 codebase is a migration, not a flag.

RHF✗ v7 · ✓ v8 beta

v8's headline feature is exactly this: "V8 adds first-class support for the React Compiler. No additional configuration is required." [4]

TanStackSafe

Store + useSyncExternalStore model. [27]

ConformSafe

No interior mutability in render.

05

Disposition

Pick by the hard pattern you actually have, not by the library you already like.

Repeating groups with reorder
RHF

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]

Conditional fields
Decide the payload

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.

Dependent async validation
TanStack / Conform

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]

Wizards
TanStack

FormGroup is the only first-class wizard primitive of the three. RHF's own docs tell you to bring a state library. [13]

100+ fields
RHF

With useFormState pushed to the leaves. Benchmark TanStack's field mount cost against your field count before committing. [15]

Nobody persists for you
localStorage

None of the three writes to localStorage. That's your subscribe() (RHF, renderless [5]) or a store subscription (TanStack), plus a debounced write.