RHF ↔ TanStack Form Migration
Guided rewrite (not drop-in) between react-hook-form and @tanstack/react-form.
Verified against (23 Aug 2026): React Hook Form 7.86.0 (released 21 Aug 2026) and @tanstack/react-form 1.33.5 (11 Aug 2026). Do not use TanStack Form 2.0.0-alpha for this skill. API semantics re-checked against official docs (Context7) on 2 Sep 2026; package versions above not re-verified then.
API notes: RHF 7.72+ validate is exclusive with resolver (if both are set, only resolver runs). RHF also has setValues, getErrors, and subscribe. TanStack Form listeners (side effects) is separate from onChangeListenTo (revalidation).
When to use
- Migrating existing forms RHF → TanStack Form (or reverse)
- Mapping APIs while rewriting a form component
- Explaining dirty / validation / array / composition differences
- Deciding which library fits a codebase
When not to use
- Greenfield simple forms — build with one library; do not force a migration skill
- Non-React TanStack adapters (Vue/Solid/Angular) — out of scope
- Formik, Final Form, Formisch migrations — out of scope
- Pure styling/a11y form review without library cutover
Quick decision guide
| Prefer React Hook Form | Prefer TanStack Form |
|---|---|
| Simple/moderate forms, native inputs | Complex multi-step / multi-file forms |
Uncontrolled register DX, smaller mental model |
Deep TypeScript inference + controlled model |
| Mature ecosystem, team already productive | Per-event validators + built-in async debounce |
| “Dirty = differs from default” UX | Composition (createFormHook, field groups) |
If current forms work and pain is low: do not migrate.
Mental model
| Axis | RHF | TanStack Form |
|---|---|---|
| Control | Uncontrolled-first (register) |
Controlled-first (value + handleChange) |
| Validation | Form-level mode + resolver |
Per-event validators (onChange / onBlur / onSubmit + async) |
| Schema | @hookform/resolvers |
Standard Schema (Zod etc.) directly |
| Arrays | useFieldArray |
form.Field mode="array" |
| Paths | users.0.name |
users[0].name |
| Dirty | Non-persistent (≠ default) | Persistent (use !isDefaultValue for RHF-like) |
| Composition | FormProvider |
createFormHook + AppField / withForm |
| Subscribe | watch / useWatch / Proxy formState |
form.Subscribe / useSelector |
TanStack Form docs: pass Zod v3.24+ via Standard Schema (schema goes directly into validators; no adapter). Older Zod does not implement the interface.
Full tables: references/api-mapping.md
Core API cheat sheet
RHF TSF
─────────────────────────────────────────────────────────────
useForm useForm (@tanstack/react-form)
register form.Field (controlled)
Controller form.Field
useFieldArray form.Field mode="array"
append / remove pushValue / removeValue
watch / useWatch form.Subscribe / useSelector
FormProvider createFormHook + withForm
errors.x.message field.state.meta.errors[]
isDirty isDirty (persistent!) or !isDefaultValue
setError onSubmitAsync { fields, form }
setValue setFieldValue
reset form.reset (+ preventDefault on type=reset)
handleSubmit(fn) onSubmit option + form.handleSubmit()
resolver: zodResolver(z) validators: { onChange: z }
mode: 'onBlur' validators.onBlur (+ handleBlur)
deps: ['a'] onChangeListenTo: ['a']
users.0.name users[0].name
Minimal side-by-side (bootstrap)
RHF
const { register, handleSubmit, formState: { errors } } = useForm({
defaultValues: { email: '' },
resolver: zodResolver(schema),
})
<form
<input {...register('email')} />
{errors.email?.message}
</form>
TSF
const form = useForm({
defaultValues: { email: '' },
validators: { onChange: schema },
onSubmit: async ({ value }) => { /* ... */ },
})
<form => { e.preventDefault(); form.handleSubmit() }}>
<form.Field name="email" children={(field) => (
<>
<input
value={field.state.value}
=> field.handleChange(e.target.value)}
/>
{!field.state.meta.isValid && <em>{field.state.meta.errors.join(', ')}</em>}
</>
)} />
</form>
More examples: references/side-by-side-examples.md
Workflow: RHF → TanStack Form
Follow in order. Detailed ticks: references/checklists.md.
1. Inventory
Prefer the bundled script (counts + suggested direction):
bash scripts/inventory.sh /path/to/project
Or grep:
rg -n "from ['\"]react-hook-form['\"]|useFieldArray|Controller|FormProvider" --glob '*.{ts,tsx}'
Classify each form: simple · arrays · multi-step · shared composition.
2. Dependencies
- Add
@tanstack/react-form(pin exact — types can change in patches) - Keep RHF until the last form is migrated
3. Defaults and schemas
- Extract Zod/schema modules
- Provide complete
defaultValues(noundefinedholes) — TSF infers paths from them - Drop
zodResolverusage on migrated forms; pass schema intovalidators
4. Composition strategy
| Scale | Approach |
|---|---|
| 1–2 simple forms | Raw useForm + form.Field |
| App-scale / shared inputs | createFormHook + AppField kit first |
Avoid rewriting 20 forms with giant render props before shared field components exist.
5. Rewrite fields
register/Controller→ controlledform.Field- Array paths:
list.${i}.x→`list[${i}].x` useFieldArray→mode="array"+pushValue/removeValue/ …- Prefer stable keys when reordering (TSF does not give RHF-style
field.id)
6. Validation and async
- Map RHF
modeto intentional event validators (not everything on every keystroke) - Cross-field:
deps/validate→onChangeListenToor form-level.refine - Async:
onChangeAsync+onChangeAsyncDebounceMs(first-class in TSF) - Server errors: prefer
onSubmitAsyncreturning{ form, fields }maps over ad-hocsetError
7. Reactivity and dirty UX
watch/useWatch→form.Subscribe/useSelectorwith narrow selectors- Submit disable: prefer
canSubmit+isSubmitting - Revisit dirty banners — TSF dirty is persistent; emulate RHF with
!isDefaultValueif product needs it - Errors display: join
meta.errorsarrays
8. Submit / reset
onSubmit={(e) => {
e.preventDefault()
e.stopPropagation()
form.handleSubmit()
}}
// reset:
<button type="button" => form.reset()}>Reset</button>
9. Verify then remove RHF
- Typecheck migrated modules
- QA: empty submit, async, cross-field, arrays, dirty, server errors, reset
- When zero RHF imports remain: remove
react-hook-formand@hookform/resolvers
Workflow: TanStack Form → RHF
Shorter reverse path (same inventory discipline).
- Add
react-hook-form(+@hookform/resolversif using Zod) defaultValues+useForm<T>()generics- Fields: native →
register; design-system →Controller - Collapse event validators into
mode+resolver/ rules - Arrays:
mode="array"→useFieldArray+key={field.id}; paths brackets → dots - Linked fields:
onChangeListenTo→deps/ refine /trigger - Async debounce: implement manually
- Server errors →
setError/root.*/ stableerrorsprop form.Subscribe→useWatch/useFormState(read Proxy keys you need)- Composition kit →
FormProvider+ shared components - Accept non-persistent dirty; re-test unsaved guards
- Verify → remove
@tanstack/react-form
Top pitfalls (do not skip)
- Dirty semantics differ — largest product bug after naive migrations
- Path syntax —
users.0.namevsusers[0].name - Errors are arrays in TSF — not
errors.x.message - No
registerin TSF — always controlled (or AppField) preventDefaulton submit and reset for TSF- Field validators can overwrite form-level field errors on the same event
canSubmittiming ≠ RHFisValiduntil touched- Verbose Field JSX — introduce composition early
- Pin TSF exactly for stable types
- Do not half-remove resolvers — clean package.json after full cutover
- shadcn
Form+control={form.control}fields — not portable; rewrite field helpers or build a TSFAppFieldkit first (common in RHF apps) - Enum / literal defaultValues —
role: "viewer"alone can narrow TSF inference to"viewer"only; typedefaultValuesas the full form type - Error display —
isTouched && errorshides form-levelonSubmitschema failures; useisTouched || submissionAttempts > 0 - RHF
valuesprop — no 1:1 in TSF;form.reset(next)/setFieldValuewhen server props change
Full list: references/pitfalls.md
shadcn / design-system form layer (real codebases)
shadcn Forms is now a library picker (React Hook Form | TanStack Form | others), not an RHF-only kit. Official guides:
- React Hook Form: https://ui.shadcn.com/docs/forms/react-hook-form
- TanStack Form: https://ui.shadcn.com/docs/forms/tanstack-form
Most production RHF apps do not use bare register. They look like Dialyx:
// RHF + shadcn pattern (NOT drop-in to TSF)
const form = useForm({ resolver: zodResolver(schema), defaultValues })
<Form {...form}>
<CustomFormField control={form.control} name="email" />
</Form>
Migration cost is dominated by this layer:
| Layer | Action |
|---|---|
components/ui/form.tsx (FormProvider/Controller) |
Keep for remaining RHF forms, or dual-run |
CustomFormField (control prop) |
Cannot pass TSF form; rewrite consumers to form.Field or build TSF-aware field components |
| One dialog using CustomFormField | Inline form.Field + shared Input/Select (acceptable pilot) |
| Many dialogs | Stop — invent createFormHook + AppField kit before mass rewrite |
Skill accuracy note: mapping tables are high-precision for raw RHF APIs. Precision drops if the agent only swaps useForm import and leaves control={...} helpers unchanged.
Post-migration validation (minimum)
- Empty submit shows required errors with expected timing
- Fixing a field clears its error
- Cross-field revalidates when sibling changes
- Array add/remove keeps correct values
- Dirty/unsaved UX matches product intent
- Server field errors map correctly
- Reset returns to form defaults
- Project typecheck green
Agent procedure (execute this)
When asked to migrate forms:
- Confirm direction (RHF→TSF or TSF→RHF) and whether migration is justified
- Inventory with scripts/inventory.sh (or greps above); list forms by complexity
- Load references/api-mapping.md for the APIs you will touch
- Migrate one form end-to-end as a template (prefer a medium-complexity form)
- Extract shared field components if more forms remain
- Apply references/checklists.md per form
- QA post-migration list
- Remove old package only when inventory is empty
- Summarize semantic risks called out (dirty, paths, errors[], canSubmit)
Prefer small, reviewable diffs. Never claim migration complete without typecheck evidence on touched files.
Official docs
- React Hook Form: https://react-hook-form.com/docs/useform
- RHF field array: https://react-hook-form.com/docs/usefieldarray
- TanStack Form overview: https://tanstack.com/form/latest/docs/overview
- TSF basic concepts (dirty, arrays, validation): https://tanstack.com/form/latest/docs/framework/react/guides/basic-concepts
- TSF comparison: https://tanstack.com/form/latest/docs/comparison
- TSF form composition: https://tanstack.com/form/latest/docs/framework/react/guides/form-composition
- shadcn Forms (RHF): https://ui.shadcn.com/docs/forms/react-hook-form
- shadcn Forms (TanStack Form): https://ui.shadcn.com/docs/forms/tanstack-form
References
- api-mapping.md — complete API tables
- side-by-side-examples.md — basic, select, arrays, cross-field+async, composition
- pitfalls.md — both directions
- checklists.md — executable checklists + decision gates