TanStack
Guides

Form and Field Validation

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.

Choose when validation runs

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.

tsx
<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:

  • runOnMount: run once when the form is constructed or when a field or form group first mounts.
  • runOnSubmit: disable or conditionally enable submit-time execution.
  • triggerDebounceMs: debounce change and blur execution.
  • bailIfInvalid: skip this and subsequent validators when an earlier one failed.
  • trigger objects with when: enable a trigger conditionally.
  • watchFields: rerun a field validator when related fields trigger it.

Control when errors are visible

Validation and presentation are separate. errorVisibility decides when field issues appear in field.errors:

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

Form-level validation

A form validator can produce a form issue and route issues to typed field paths with createErrorMap:

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

Asynchronous validation and debouncing

run may return a promise. Put cheap synchronous checks first and use bailIfInvalid to avoid unnecessary requests:

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

Standard Schema validation

Any Standard Schema implementation can be supplied as run:

tsx
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:

tsx
<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>

Return errors from submission

Endpoint validation belongs in onSubmit. Return createValidationError(...) to feed server issues back into normal form state:

tsx
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
}

Prevent invalid submission

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:

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