TanStack Form Patterns
Quick Guide: Use
useFormwithdefaultValuesand typed generics. Render fields withform.Fieldusing the render-propchildrenpattern. Validation lives in thevalidatorsprop on both form and field level — useonChange,onBlur,onSubmit(sync) and theirAsyncvariants. Usemode="array"for dynamic field lists withpushValue/removeValue. UseonChangeListenTofor cross-field validation. For app-wide consistency, create a shareduseAppFormviacreateFormHook. Always providedefaultValues— TanStack Form infers types from them.
<critical_requirements>
CRITICAL: Before Using This Skill
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type, named constants)
(You MUST provide defaultValues to useForm — TanStack Form infers field types from them)
(You MUST use form.Field with the children render prop — TanStack Form does not use register or Controller)
(You MUST use the validators prop for validation — NOT inline rules or external resolver wrappers)
(You MUST handle field.state.meta.errors as an array — always .map() over errors)
(You MUST call form.handleSubmit() inside the form's onSubmit handler with e.preventDefault())
</critical_requirements>
Auto-detection: TanStack Form, @tanstack/react-form, @tanstack/vue-form, @tanstack/solid-form, @tanstack/angular-form, @tanstack/lit-form, useForm from tanstack, form.Field, createFormHook, createFormHookContexts, useAppForm, fieldContext, formContext, handleSubmit tanstack, pushValue, removeValue, onChangeListenTo, field.handleChange, field.handleBlur, field.state, formDevtoolsPlugin
When to use:
- Building type-safe forms where field types are inferred from
defaultValues - Managing complex validation with sync, async, and cross-field rules
- Dynamic forms with add/remove field groups (array fields)
- Multi-framework projects (React, Vue, Solid, Angular, Lit)
- Projects already using the TanStack ecosystem
When NOT to use:
- Single input without validation (use native state)
- Server-only forms with server actions (use native form + action)
- Read-only data display (not a form scenario)
Table of Contents
Detailed Resources:
- examples/core.md - Basic form, Field component, TypeScript, form submission
- examples/validation.md - Sync/async validation, validator adapters, form-level validation
- examples/arrays.md - Dynamic array fields with pushValue/removeValue
- examples/composition.md - createFormHook, useAppForm, listeners, side effects
- reference.md - API tables, validator events, decision frameworks
<philosophy>
Philosophy
TanStack Form is headless and type-safe by design. It owns zero UI — you render every input yourself. The library provides form state, validation orchestration, and field management. Types flow from defaultValues through every field name, value, and error — no manual generics required (though you can provide them).
Core Principles:
- Type inference from defaults -
defaultValuesdefines the form shape; field names and values are fully typed - Headless - Zero UI opinions; works with any component library or native inputs
- Validation-event-driven - Validators attach to specific events (
onChange,onBlur,onSubmit) per field or per form - Framework-agnostic core - Same mental model across React, Vue, Solid, Angular, and Lit
- Composition via factory -
createFormHookshares field/form components across an app
<patterns>
Core Patterns
Pattern 1: Basic useForm + form.Field
Every form starts with useForm and renders fields via form.Field. The children render prop receives the field API with state, handleChange, and handleBlur.
import { useForm } from "@tanstack/react-form";
const form = useForm({
defaultValues: { name: "", email: "" },
onSubmit: async ({ value }) => {
await submitToApi(value);
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
>
<form.Field
name="email"
children={(field) => (
<input
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
)}
/>
</form>
);
Key difference from other form libraries: No register, no Controller, no ref forwarding. You always use field.handleChange and field.state.value explicitly.
See examples/core.md for complete form with error display and accessibility.
Pattern 2: Field-Level Validation
Validators are functions on the validators prop. Sync validators return a string (error) or undefined (valid). Async validators use onChangeAsync, onBlurAsync, onSubmitAsync.
<form.Field
name="age"
validators={{
onChange: ({ value }) => (value < 13 ? "Must be 13 or older" : undefined),
onBlurAsync: async ({ value }) => {
const exists = await checkAge(value);
return exists ? undefined : "Age not valid on server";
},
}}
children={(field) => (
<div>
<input
type="number"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.valueAsNumber)}
/>
{field.state.meta.errors.map((err) => (
<em key={err} role="alert">
{err}
</em>
))}
</div>
)}
/>
Sync-first gating: When both onBlur and onBlurAsync exist, the async validator only runs if the sync validator passes. Same for onChange/onChangeAsync.
See examples/validation.md for all validation patterns and adapter integration.
Pattern 3: Linked Fields (Cross-Field Validation)
Use onChangeListenTo to re-run a field's validator when another field changes. This solves the stale-validation problem (e.g., confirm password).
<form.Field
name="confirm_password"
validators={{
onChangeListenTo: ["password"],
onChange: ({ value, fieldApi }) => {
if (value !== fieldApi.form.getFieldValue("password")) {
return "Passwords do not match";
}
return undefined;
},
}}
children={(field) => (/* ... */)}
/>
Why this matters: Without onChangeListenTo, changing the password field does not re-validate confirm_password. The error stays stale until the user interacts with the confirm field again.
See examples/validation.md Pattern 4 for a complete linked fields example.
Pattern 4: Array Fields
Use mode="array" on form.Field to get pushValue, removeValue, swapValues, moveValue, and insertValue for dynamic field groups.
<form.Field
name="hobbies"
mode="array"
children={(hobbiesField) => (
<div>
{hobbiesField.state.value.map((_, i) => (
<div key={i}>
<form.Field
name={`hobbies[${i}].name`}
children={(field) => (
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
)}
/>
<button type="button" onClick={() => hobbiesField.removeValue(i)}>
Remove
</button>
</div>
))}
<button
type="button"
onClick={() => hobbiesField.pushValue({ name: "" })}
>
Add hobby
</button>
</div>
)}
/>
Important: pushValue requires a complete object matching the array item shape. Partial objects will cause type errors.
See examples/arrays.md for a complete dynamic list form.
Pattern 5: Form-Level Validation
Validators on useForm apply