📝TanStack Form
TanStack Form is a headless form library. It gives you form state, validation and submission, and you bring your own markup. Field names and values are fully typed from defaultValues, and each field subscribes to its own slice of state, so typing in one input doesn’t re-render the whole form. Reach for it when a form has client-side validation, async checks, dynamic arrays or linked fields. For a simple form that posts and reloads, a plain <form> with FormData (or a Server Action) is enough.
Install
npm i @tanstack/react-form
Basic form
useForm holds the state. form.Field renders one field through a render prop and hands you field.state plus the handlers. Call e.preventDefault() yourself, then form.handleSubmit().
import { useForm } from '@tanstack/react-form'
export function SignupForm() {
const form = useForm({
defaultValues: {
email: '',
},
onSubmit: async ({ value }) => {
console.log(value)
},
})
return (
<form
onSubmit={(e) => {
e.preventDefault()
form.handleSubmit()
}}
>
<form.Field
name="email"
validators={{
onChange: ({ value }) =>
value.includes('@') ? undefined : 'Enter a valid email',
}}
children={(field) => {
const showError =
field.state.meta.isTouched && !field.state.meta.isValid
return (
<div>
<label htmlFor={field.name}>Email</label>
<input
id={field.name}
name={field.name}
type="email"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={showError}
aria-describedby={showError ? `${field.name}-error` : undefined}
/>
{showError && (
<p id={`${field.name}-error`}>
{field.state.meta.errors.join(', ')}
</p>
)}
</div>
)
}}
/>
<form.Subscribe
selector={(state) => [state.canSubmit, state.isSubmitting]}
children={([canSubmit, isSubmitting]) => (
<button type="submit" disabled={!canSubmit}>
{isSubmitting ? 'Saving…' : 'Sign up'}
</button>
)}
/>
</form>
)
}
field.handleBlursetsisTouched, so errors wait until the user has left the field.form.Subscribere-renders only when the selected values change. Use it for anything that reads form-wide state.canSubmitstaystrueuntil the form is touched, then goesfalsewhile any field is invalid.
Validation
Field validators
Each validator gets { value, fieldApi } and returns an error or undefined. Pick when it runs with the key: onChange, onBlur, onSubmit or onMount. Every sync key has an async twin (onChangeAsync, onBlurAsync, …) with its own debounce (onChangeAsyncDebounceMs). This is where a “username taken” check goes (compare with a hand-rolled debounce):
import { useForm } from '@tanstack/react-form'
import { isUsernameTaken } from './api'
export function UsernameForm() {
const form = useForm({ defaultValues: { username: '' } })
return (
<form.Field
name="username"
validators={{
onChange: ({ value }) =>
value.length < 3 ? 'At least 3 characters' : undefined,
onBlur: ({ value }) =>
/^[a-z0-9_]+$/.test(value)
? undefined
: 'Lowercase letters, numbers and _ only',
onChangeAsyncDebounceMs: 500,
onChangeAsync: async ({ value }) =>
(await isUsernameTaken(value)) ? 'That username is taken' : undefined,
}}
children={(field) => (
<>
<input
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.isValidating && <span>Checking…</span>}
{field.state.meta.isTouched && (
<em>{field.state.meta.errors.join(', ')}</em>
)}
</>
)}
/>
)
}
- The async validator only runs when the sync one passes. Set
asyncAlways: trueto change that. field.state.meta.errorsholds every current error.field.state.meta.errorMap.onBlur(etc.) gives you the error from one trigger.asyncDebounceMson the field sets a default debounce for all its async validators.
Schema validation with Zod
Any Standard Schema library (Zod, Valibot, ArkType, Effect Schema) works directly. No adapter needed. Pass the schema to the form’s validators and the errors land on the matching fields:
import { useForm } from '@tanstack/react-form'
import { z } from 'zod'
import { createAccount } from './api'
const schema = z.object({
name: z.string().min(1, 'Required'),
age: z.number().min(18, 'You must be 18 or over'),
})
export function AccountForm() {
const form = useForm({
defaultValues: { name: '', age: 0 },
validators: {
onChange: schema,
},
onSubmit: async ({ value }) => {
await createAccount(value)
},
})
return (
<form.Field
name="age"
children={(field) => (
<>
<input
type="number"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.valueAsNumber)}
/>
{field.state.meta.isTouched &&
field.state.meta.errors.map((error) => (
<p key={error?.message}>{error?.message}</p>
))}
</>
)}
/>
)
}
- Schema errors are issue objects, not strings, so render
error.message. onSubmitreceives the schema’s input values. If the schema transforms data, callschema.parse(value)inonSubmit.- You can also pass a schema to a single field:
validators={{ onChange: z.string().min(3) }}.
Array fields
Put mode="array" on the parent field, then render one sub-field per item with a bracketed name:
import { useForm } from '@tanstack/react-form'
export function TeamForm() {
const form = useForm({
defaultValues: {
people: [] as { name: string }[],
},
})
return (
<form.Field name="people" mode="array">
{(field) => (
<div>
{field.state.value.map((_, i) => (
<div key={i}>
<form.Field name={`people[${i}].name`}>
{(subField) => (
<input
aria-label={`Person ${i + 1} name`}
value={subField.state.value}
onChange={(e) => subField.handleChange(e.target.value)}
/>
)}
</form.Field>
<button type="button" onClick={() => field.removeValue(i)}>
Remove
</button>
</div>
))}
<button type="button" onClick={() => field.pushValue({ name: '' })}>
Add person
</button>
</div>
)}
</form.Field>
)
}
Other array helpers on the field: insertValue, replaceValue, swapValues and moveValue.
Linked fields
A field’s validators only re-run when that field changes. Use onChangeListenTo (or onBlurListenTo) to re-run them when another field changes too, e.g. confirm password:
import { useForm } from '@tanstack/react-form'
export function PasswordForm() {
const form = useForm({
defaultValues: { password: '', confirmPassword: '' },
})
return (
<form.Field
name="confirmPassword"
validators={{
onChangeListenTo: ['password'],
onChange: ({ value, fieldApi }) =>
value !== fieldApi.form.getFieldValue('password')
? 'Passwords do not match'
: undefined,
}}
children={(field) => (
<input
type="password"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
)}
/>
)
}
With TanStack Query
Load with useQuery, save with useMutation, and await mutation.mutateAsync() inside onSubmit. isSubmitting stays true until that promise settles. See React Query for the Query side.
Render the form only after the data is there, so defaultValues is correct on the first render. To put server errors back on fields, call formApi.setErrorMap() with a { form, fields } object. Each field error clears when the user edits that field.
import { useForm } from '@tanstack/react-form'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ApiError, getProfile, updateProfile, type Profile } from './api'
export function ProfilePage() {
const { data, isPending, isError } = useQuery({
queryKey: ['profile'],
queryFn: getProfile,
})
if (isPending) return <p>Loading…</p>
if (isError) return <p>Could not load your profile</p>
return <ProfileForm profile={data} />
}
function ProfileForm({ profile }: { profile: Profile }) {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: updateProfile,
onSuccess: (saved) => queryClient.setQueryData(['profile'], saved),
})
const form = useForm({
defaultValues: profile,
onSubmit: async ({ value, formApi }) => {
try {
const saved = await mutation.mutateAsync(value)
formApi.reset(saved)
} catch (error) {
if (error instanceof ApiError) {
formApi.setErrorMap({
onSubmit: { form: error.message, fields: error.fields },
})
return
}
throw error
}
},
})
return (
<form
onSubmit={(e) => {
e.preventDefault()
form.handleSubmit()
}}
>
{/* fields… */}
<form.Subscribe
selector={(state) => state.errorMap.onSubmit}
children={(error) =>
error ? <p role="alert">{String(error)}</p> : null
}
/>
</form>
)
}
formApi.reset(saved)sets the values and the new defaults, so the form is clean again after a save.- A server check you want to run as part of validation, before
onSubmit, can go in the form’svalidators.onSubmitAsync. It returns the same{ form, fields }shape. - If
onSubmitreturns normally aftersetErrorMap,isSubmitSuccessfulis stilltrue. CheckcanSubmitor the error map, not that flag.
Reusable fields with createFormHook
The docs recommend this for real apps. Bind your UI components to a custom useAppForm once, and each form gets form.AppField with field.TextField and friends, all still typed.
// form-context.tsx
import { createFormHookContexts } from '@tanstack/react-form'
export const { fieldContext, formContext, useFieldContext, useFormContext } =
createFormHookContexts()
// fields.tsx
import { useId } from 'react'
import { useFieldContext, useFormContext } from './form-context'
export function TextField({ label }: { label: string }) {
const field = useFieldContext<string>()
const id = useId()
const showError = field.state.meta.isTouched && !field.state.meta.isValid
return (
<div>
<label htmlFor={id}>{label}</label>
<input
id={id}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={showError}
aria-describedby={showError ? `${id}-error` : undefined}
/>
{showError && (
<p id={`${id}-error`}>
{field.state.meta.errors
.map((error) =>
typeof error === 'string' ? error : error?.message,
)
.join(', ')}
</p>
)}
</div>
)
}
export function SubmitButton({ label }: { label: string }) {
const form = useFormContext()
return (
<form.Subscribe
selector={(state) => [state.canSubmit, state.isSubmitting]}
children={([canSubmit, isSubmitting]) => (
<button type="submit" disabled={!canSubmit}>
{isSubmitting ? 'Saving…' : label}
</button>
)}
/>
)
}
// app-form.tsx
import { createFormHook } from '@tanstack/react-form'
import { z } from 'zod'
import { fieldContext, formContext } from './form-context'
import { SubmitButton, TextField } from './fields'
export const { useAppForm } = createFormHook({
fieldContext,
formContext,
fieldComponents: { TextField },
formComponents: { SubmitButton },
})
export function ContactForm() {
const form = useAppForm({
defaultValues: { name: '', email: '' },
validators: {
onChange: z.object({
name: z.string().min(1, 'Required'),
email: z.email('Enter a valid email'),
}),
},
onSubmit: ({ value }) => console.log(value),
})
return (
<form
onSubmit={(e) => {
e.preventDefault()
form.handleSubmit()
}}
>
<form.AppField
name="name"
children={(field) => <field.TextField label="Name" />}
/>
<form.AppField
name="email"
children={(field) => <field.TextField label="Email" />}
/>
<form.AppForm>
<form.SubmitButton label="Send" />
</form.AppForm>
</form>
)
}
useAppFormtakes everyuseFormoption.form.AppFieldtakes everyform.Fieldprop.- Components in
formComponentsmust sit inside<form.AppForm>to get the form context. - To split a big form across components, use
withFormfrom the samecreateFormHookcall.
Gotchas and accessibility
- Always call
e.preventDefault()in the<form>onSubmit, or the browser does a full-page submit. - Give a reset button
type="button"or calle.preventDefault()beforeform.reset(). A native reset event can clear<select>elements unexpectedly. - Pair every input with a
<label htmlFor>and a matchingid.field.nameworks for flat forms. UseuseId()in shared components or array rows, where names contain brackets or repeat. - Set
aria-invalidand pointaria-describedbyat the error element, but only once there is an error to show. - Don’t show errors before
isTouched.onChangevalidators run on the first keystroke. On submit,handleSubmitmarks every field as touched, so the errors appear then. disabledsubmit buttons can’t be focused and give no reason. Consideraria-disabledand letonSubmitInvalidmove focus to the first error.- Import types such as
AnyFieldApifrom@tanstack/react-formif you need to type a helper that takes a field.