TanStack Form supports synchronous, asynchronous, field-level, form-level, and Standard Schema validation. Every validator is an ordered object with a run implementation and explicit triggers.
Use change for immediate feedback and blur for feedback after leaving an input. Submission runs validators by default regardless of their configured change and blur triggers.
<form.Field
name="age"
validators={[
{
triggers: ['change'],
run: ({ value }) => (value >= 13 ? undefined : 'You must be at least 13'),
},
{
triggers: ['blur'],
run: ({ value }) => (value >= 0 ? undefined : 'Age cannot be negative'),
},
]}
>
{(field) => (
<label>
Age
<input
name={field.name}
type="number"
value={field.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.valueAsNumber)}
aria-invalid={field.meta.isInvalid}
/>
{field.errors.map((error) => (
<span key={error.message} role="alert">
{error.message}
</span>
))}
</label>
)}
</form.Field>Additional controls include:
Validation and presentation are separate. errorVisibility decides when field issues appear in field.errors:
const form = useForm({
defaultValues: { email: '' },
errorVisibility: ({ fieldState, state }) =>
fieldState.meta.isBlurred || state.submissionAttempts > 0,
})Use field.meta.original.errors for the unfiltered issues when debugging or building a custom visibility system.
A form validator can produce a form issue and route issues to typed field paths with createErrorMap:
const form = useForm({
defaultValues: {
email: '',
phone: '',
},
validators: [
{
triggers: ['change'],
run: ({ value, createErrorMap }) => {
const errors = createErrorMap()
if (!value.email && !value.phone) {
errors.form = 'Provide an email address or phone number'
errors.fields.email = 'Email is required when phone is empty'
errors.fields.phone = 'Phone is required when email is empty'
}
return errors
},
},
],
})Use form.Subscribe to reactively select form-level issues from form.state.errors. Lower-level integrations can subscribe to form.atom. Field-routed issues appear in the corresponding field.errors.
run may return a promise. Put cheap synchronous checks first and use bailIfInvalid to avoid unnecessary requests:
validators={[
{
triggers: ['change'],
run: ({ value }) =>
value.length >= 3 ? undefined : 'Use at least 3 characters',
},
{
triggers: ['change'],
triggerDebounceMs: 500,
bailIfInvalid: true,
run: async ({ value }) => {
const available = await checkUsername(value)
return available ? undefined : 'That username is already taken'
},
},
]}Submit validation always runs immediately, even when a trigger is debounced. field.meta.isValidating and form.state.isValidating expose pending work.
Any Standard Schema implementation can be supplied as run:
import { z } from 'zod'
const accountSchema = z.object({
email: z.string().email(),
age: z.number().min(13),
})
const form = useForm({
defaultValues: { email: '', age: 0 },
validators: [
{
triggers: ['change'],
run: accountSchema,
},
],
})Schema paths are routed to matching fields. Parsed outputs are available by validator index in schemaOutputs during submit (for example, schemaOutputs[0]); value remains the form's raw editable state rather than the parsed output.
For custom schema routing, call a schema's safe parse API inside run and pass its issues to the provided parseIssues helper.
Use watchFields when one field's validity depends on another:
<form.Field
name="endDate"
validators={[
{
triggers: ['change', 'blur'],
watchFields: ['startDate'],
run: ({ value, formApi }) =>
value >= formApi.getFieldValue('startDate')
? undefined
: 'End date must be on or after the start date',
},
]}
>
{(field) => (
<input
name={field.name}
type="date"
value={field.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
aria-invalid={field.meta.isInvalid}
/>
)}
</form.Field>Endpoint validation belongs in onSubmit. Return createValidationError(...) to feed server issues back into normal form state:
onSubmit: async ({ value, createValidationError }) => {
const result = await saveProfile(value)
if (!result.ok) {
return createValidationError({
form: 'Could not save the profile',
fields: { email: 'This email is already registered' },
})
}
return null
}form.handleSubmit() skips onSubmit when validation fails and resolves with the validation errors. onSubmitInvalid is currently available on form groups; root-form support is planned but not yet implemented. Subscribe to canSubmit and isSubmitting to reflect that state in the submit UI:
<form.Subscribe selector={(state) => [state.canSubmit, state.isSubmitting]}>
{([canSubmit, isSubmitting]) => (
<button type="submit" disabled={!canSubmit || isSubmitting}>
{isSubmitting ? 'Submitting…' : 'Submit'}
</button>
)}
</form.Subscribe>If a disabled submit button would hide useful validation feedback, keep it focusable with aria-disabled and let handleSubmit() report the issues.