TanStack Form Validation

TanStack Form is a headless, type-safe form library with adapters for React, Vue, Angular, Solid, Svelte and Lit. Its validation model is explicit about when each rule runs: every field and the form itself accept validators keyed by event — onChange, onBlur, onSubmit, onMount — plus asynchronous variants with built-in debouncing. It accepts Standard Schema validators directly, so Zod, Valibot and ArkType schemas plug in without resolvers. That explicitness is a good match for accessible validation, where timing decides whether errors help or nag. This topic covers field- and form-level validators, schema integration, async checks, error rendering with aria-describedby and aria-invalid, and focus on failed submit — keeping the browser’s Constraint Validation API involved where it helps.

The mental model is small: a form is a store of values plus per-field metadata (touched, blurred, validating, errors by event); validators are functions or schemas attached to events; and rendering reads the metadata. Everything accessible — labels, descriptions, aria-invalid, focus — lives in the components you render from that metadata, which is why one well-built field component does most of the work in a TanStack Form codebase.

The problem this topic solves is timing sprawl: forms where some rules run on change, some on blur, some only on submit, with no single place that says which is which. TanStack Form makes that declaration part of each field.

TanStack Form validation events A field's value changes trigger onChange validators, leaving the field triggers onBlur validators, async validators run after their debounce, and submit runs onSubmit validators for every field and the form. onMount initial checks (rare) onChange cheap sync rules onBlur first verdict for most fields onChangeAsync / onBlurAsync debounced server checks onSubmit everything, then submit
Each validator is attached to an event, so the timing of every rule is visible where the field is declared.

Prerequisites for TanStack Form

Requirement Minimum version Why it is needed
@tanstack/react-form (or another adapter) 1.x The form library
TypeScript 5.0+ Inferred field names and value types
A Standard Schema library (optional) Zod 3.24+, Valibot 1.0+, ArkType 2.0+ Schema validators without adapters
React 18+ For the React adapter
Testing Library 14+ Component tests
Server validation any TanStack Form is client-side; the server re-checks

TanStack Form Validation API Reference

API Where Purpose Notes
validators.onChange field / form Runs on every value change Keep cheap and synchronous
validators.onBlur field / form Runs when the field loses focus Good default for first verdict
validators.onSubmit field / form Runs on submit Catch-all
validators.onChangeAsync / onBlurAsync field Async checks Paired with …AsyncDebounceMs
asyncDebounceMs field / form Debounce for async validators Per-event variants available
Standard Schema as validator field / form e.g. onChange: z.string().email() No adapter needed
field.state.meta.errors field render Current error list Map to your message markup
field.state.meta.isTouched / isBlurred field render Interaction state Gate visibility
form.state.isSubmitting / canSubmit form Submission state Do not use canSubmit to disable the button

Step-by-Step Implementation

1. Declare fields with event-specific validators

import { useForm } from "@tanstack/react-form";
import { z } from "zod";

export function SignupForm() {
  const form = useForm({
    defaultValues: { email: "", password: "", username: "" },
    onSubmit: async ({ value }) => { await createAccount(value); },
  });

  return (
    <form noValidate onSubmit={(e) => { e.preventDefault(); e.stopPropagation(); void form.handleSubmit(); }}>
      <form.Field
        name="email"
        validators={{
          onBlur: z.string().email("Enter an email address like name@example.com."),   // Standard Schema
          onSubmit: z.string().min(1, "Enter your email address."),
        }}
        children={(field) => <TextField field={field} label="Email address" type="email" autoComplete="email" />}
      />
      <form.Field
        name="password"
        validators={{
          onChange: ({ value }) => ([...value].length >= 12 ? undefined : "Password must be at least 12 characters."),
        }}
        children={(field) => <TextField field={field} label="Password" type="password" autoComplete="new-password" />}
      />
      <form.Subscribe selector={(s) => s.isSubmitting}
        children={(isSubmitting) => <button type="submit" aria-disabled={isSubmitting}>{isSubmitting ? "Creating…" : "Create account"}</button>}
      />
    </form>
  );
}

Standard Schema validators can be passed directly as a validator — the Zod schema above validates the field’s value and returns its message on failure. Mixing schemas and plain functions per field is normal; the event keys make each rule’s timing explicit.

2. Render errors accessibly from field state

import type { AnyFieldApi } from "@tanstack/react-form";

function TextField({ field, label, ...inputProps }: { field: AnyFieldApi; label: string } & React.InputHTMLAttributes<HTMLInputElement>) {
  const { meta } = field.state;
  const message = meta.errors.map((e) => (typeof e === "string" ? e : e?.message)).find(Boolean);
  const show = Boolean(message) && (meta.isBlurred || field.form.state.submissionAttempts > 0);
  const id = field.name;
  return (
    <div className="field">
      <label htmlFor={id}>{label}</label>
      <input id={id} name={id} value={field.state.value}
             onChange={(e) => field.handleChange(e.target.value)} onBlur={field.handleBlur}
             aria-invalid={show ? true : undefined} aria-describedby={`${id}-err`} {...inputProps} />
      <p id={`${id}-err`} className="field-error" hidden={!show}>{show ? message : ""}</p>
    </div>
  );
}

Errors from schema validators arrive as issue objects and from functions as strings, so the component normalises both. The isBlurred || submissionAttempts > 0 gate keeps an onChange rule from showing its error while the user is still typing for the first time, the timing described in validating on blur versus on input.

3. Focus the first invalid field after a failed submit

const formRef = useRef<HTMLFormElement>(null);
const form = useForm({
  defaultValues,
  onSubmit: async ({ value }) => save(value),
  onSubmitInvalid: () => {
    // Runs when submit validation fails; the DOM now reflects aria-invalid.
    requestAnimationFrame(() => formRef.current?.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus());
  },
});

TanStack Form does not move focus by itself. onSubmitInvalid is the hook for it; focusing the first element marked aria-invalid in document order mirrors reportValidity(). For several errors, focus an accessible error summary instead.

4. Add async checks with built-in debouncing

<form.Field
  name="username"
  asyncDebounceMs={400}
  validators={{
    onChange: ({ value }) => (value.length < 3 ? "Username must be at least 3 characters." : undefined),
    onChangeAsync: async ({ value, signal }) => {
      const res = await fetch(`/api/usernames/available?u=${encodeURIComponent(value)}`, { signal });
      return (await res.json()).available ? undefined : "That username is taken.";
    },
  }}
  children={(field) => <TextField field={field} label="Username" autoComplete="username" />}
/>

The sync onChange rule runs first; the async one only runs when the sync one passes, after the debounce, and receives an AbortSignal that TanStack Form aborts when a newer run starts. The full recipe is TanStack Form async field validators.

Sync then async validation for one field A keystroke runs the synchronous onChange validator; if it passes, the async validator runs after the debounce with an abort signal, and a newer keystroke aborts the previous async run. User Field onChange onChangeAsync types "ada_l" length ≥ 3? yes after 400 ms (signal) types "ada_lo" (previous run aborted) "That username is taken." → meta.errors
Cheap rules gate the expensive one, and TanStack Form handles debounce and cancellation for async validators.

State Management and Edge Cases

TanStack Form keeps state in a store with fine-grained subscriptions, so only fields whose state changed re-render. A few behaviours are worth knowing:

  • canSubmit is not a reason to disable the button. It becomes false after a failed submit until errors are fixed; disabling the button with it hides the reason. Keep the button enabled and let submit reveal errors.
  • Errors per event. A field can hold errors from different events at once (errorMap.onChange, errorMap.onBlur). Render the first meaningful one, not all of them.
  • Linked fields. A confirmation field that depends on the password uses onChangeListenTo: ["password"] so its validator re-runs when the password changes — without it, the confirmation error goes stale.
  • Reset after success. Call form.reset() after a successful submit if the form stays on screen, so touched and blurred flags return to their initial state and errors do not reappear on the next edit.
  • Default values loaded later. When defaults come from an API, pass them once they are available (or call form.reset(newDefaults)), otherwise every field starts dirty and validators run against placeholder values.
  • Server errors. Map server errors onto fields with form.setFieldMeta(name, (m) => ({ ...m, errorMap: { ...m.errorMap, onServer: message } })) or by returning them from an onSubmitAsync form validator, so they render through the same component.
Validation timing by field type Two columns suggesting which TanStack Form validator events suit simple fields and which suit fields with live feedback or server checks. Simple fields • onBlur for format rules (email, postcode) • onSubmit for required checks ✓ no errors while typing the first time Live-feedback fields • onChange for password rules and counters • onChangeAsync + debounce for availability ✓ immediate, useful feedback ✗ gate visibility until first blur
The event keys let each field choose its timing explicitly instead of inheriting one global mode.

Accessibility Compliance With TanStack Form

TanStack Form is headless, so accessibility is determined by your field components. Build one TextField component (as above) and use it everywhere: it guarantees a <label for>, an error element referenced by aria-describedby, aria-invalid in step with the visible message, and a stable error element that is hidden rather than removed. Add focus on failed submit through onSubmitInvalid. Expose async pending states — field.state.meta.isValidating — as a “Checking…” status in the field’s description rather than only a spinner. And keep native type, inputMode and autoComplete attributes on inputs; they prevent errors and satisfy WCAG 1.3.5 even though TanStack Form does the validating.

Common Gotchas and Debugging

Forgetting e.preventDefault() in the submit handler. TanStack Form’s handleSubmit does not prevent the native submission; call preventDefault() in your form’s onSubmit or the page reloads.

Errors showing while typing. An onChange validator produces errors immediately. Gate visibility on isBlurred or submission attempts, as the field component does.

// Before
{field.state.meta.errors.length > 0 && <p>{field.state.meta.errors[0]}</p>}
// After
{show && <p id={`${field.name}-err`}>{message}</p>}

Schema issues rendered as [object Object]. Standard Schema validators return issue objects; render issue.message. The normalisation in the field component handles both shapes.

Async validators without the signal. Ignoring the signal argument means aborted runs still complete their fetch and waste requests. Pass it to fetch.

Validators defined inline in render. Declaring validator functions inline is fine for simple rules, but anything that holds state — a cache, a counter — must be created outside the render function, or it resets on every render.

Form-Level Validation and Cross-Field Rules

Form-level validators receive all values and can return errors for specific fields, which is the natural home for cross-field rules and for a whole-form Standard Schema. Returning an object of the form { fields: { confirm: "Passwords don't match." } } routes the message to the right field, where the same field component renders it. A form-level onSubmitAsync validator is also the cleanest place to call the server and map its 422 response back onto fields before onSubmit runs, keeping server errors in the same pipeline as client ones, as described in mapping server field errors to form inputs.

const form = useForm({
  defaultValues: { password: "", confirm: "" },
  validators: {
    onChange: ({ value }) =>
      value.confirm && value.confirm !== value.password
        ? { fields: { confirm: "Passwords don't match." } }
        : undefined,
    onSubmitAsync: async ({ value }) => {
      const res = await fetch("/api/signup/check", { method: "POST", body: JSON.stringify(value) });
      if (res.status === 422) return { fields: (await res.json()).errors };
      return undefined;
    },
  },
  onSubmit: async ({ value }) => createAccount(value),
});

Choosing TanStack Form Over Other Libraries

TanStack Form is a strong choice when a codebase spans several frameworks, when type safety across deeply nested values matters, or when teams want validation timing declared per field rather than set once per form. Its framework-agnostic core means a design system can share form logic between a React app and a Vue or Svelte one, and its Standard Schema support means the schema library is a free choice. React Hook Form remains the more widely used option in React-only codebases, with a larger ecosystem of examples and integrations, and its uncontrolled-input approach is very fast. Formik is best treated as legacy. The honest decision rule: if you already use one of these libraries successfully, keep it; if you are starting fresh in a multi-framework or strictly typed environment, TanStack Form deserves a serious look. Either way, the accessibility responsibilities and the need for server-side validation are identical, and the comparisons in framework integration patterns apply.

Field Arrays and Nested Values

TanStack Form addresses nested values with dotted and bracketed paths (address.city, items[2].qty) and provides array helpers — pushValue, removeValue, swapValues — on array fields. Validators attach to sub-fields exactly as to top-level ones, and errors appear in each sub-field’s meta. The identity problem that affects every form library applies here too: when rendering rows, use a stable id from the row’s data as the React key rather than the array index, or focus and errors can jump to the wrong row when one is removed. Keep list-level rules — “at least one item”, “no duplicate descriptions” — in a validator on the array field itself, and render their message in a focusable element tied to the group, since there is no single input to own it. The general approach is described in dynamic and repeating fields.

Server Integration and Progressive Enhancement

TanStack Form is a client-side library, so the server must validate every submission independently — ideally with the same Standard Schema used on the client. For full-stack React frameworks, TanStack Form offers server helpers that run the same form options in a server action and return state the client form can merge, which gives progressive enhancement similar to plain server actions: the form posts natively before hydration, and the client library takes over afterwards. Whether or not you use those helpers, the rule from server and full-stack validation holds: client validators are for experience, server validation is for truth, and server errors must come back into the same field components so users see them in the same place and wording.

Migrating to TanStack Form

Moving from Formik or React Hook Form is easiest one form at a time, behind the shared field component. Write the field component first, port each form’s rules into per-field validators (or pass the existing schema as a form-level Standard Schema validator), reproduce timing with event keys — onBlur for fields that previously validated on blur, onChange for live-feedback fields — and run the same user-facing tests before and after. The tests from migrating from Formik to React Hook Form are written against labels, messages and focus rather than library internals, so they work for a TanStack migration too.

Messages, Localisation and Consistency

Because every rule in TanStack Form can come from a different place — a schema, an inline function, a form-level validator, a server response — messages can drift in tone and wording unless you centralise them. Put every message in one catalogue keyed by stable ids, have schema refinements and inline validators return those messages, and localise the catalogue rather than the validators. Then a required-field message reads the same whether it came from a Zod schema on blur or from the server after submit. The same catalogue can feed an error summary, so its entries match the inline text exactly. The approach is described in keeping client and server error messages in sync, and it matters more with TanStack Form than with most libraries precisely because the library places no constraints on where a message comes from.

Performance With Fine-Grained Subscriptions

TanStack Form’s store lets each field component subscribe only to its own state, so typing in one field does not re-render the rest — provided components read state through the field API or form.Subscribe with a narrow selector. A common mistake is subscribing to the whole form state at the top of a large form (for example to show a submit summary), which re-renders every field on every keystroke and throws away the library’s main performance advantage. Use selectors that return only what the component needs, such as (s) => s.isSubmitting or (s) => s.errors.length, and keep expensive synchronous validators off onChange for long text fields.

Testing TanStack Form Components

Because the field component owns all accessibility wiring, test it once thoroughly and the forms built from it inherit the guarantees. Component tests with Testing Library should type, blur and submit, then assert on labels, message text, aria-invalid and focus. Async validators need fake timers or mocked fetch (MSW works well) and findBy* queries. Run a Playwright suite over real pages for the full flow, including server responses; the approach is in testing form error messages with Playwright.

Browser Compatibility Matrix

TanStack Form support across environments A table showing TanStack Form support for frameworks and for the platform features its validation relies on. Supported Notes React / Vue / Angular / Solid / Svelte / Lit ✓ Yes official adapters Standard Schema validators ✓ Yes Zod, Valibot, ArkType AbortSignal in async validators ✓ Yes all evergreen browsers Server-side validation ✗ No re-check on the server
TanStack Form runs wherever its framework adapter runs; validation relies only on standard JavaScript and AbortController.
Feature Chromium Firefox Safari Notes
TanStack Form 1.x Yes Yes Yes ES2020 output
AbortController for async validators 66+ 57+ 12.1+ Passed as signal
aria-invalid / aria-describedby Yes Yes Yes Universal screen reader support
requestAnimationFrame focus after render Yes Yes Yes Used in onSubmitInvalid

Frequently Asked Questions

How does TanStack Form decide when to validate?

Each field or form declares validators keyed by event — onChange, onBlur, onSubmit, onMount — plus async variants. A rule runs only on the events it is attached to, which makes timing explicit.

Can I use Zod or Valibot with TanStack Form?

Yes. TanStack Form accepts Standard Schema validators directly, so a Zod, Valibot or ArkType schema can be passed as a field or form validator without an adapter.

Does TanStack Form focus the first invalid field?

Not automatically. Use the onSubmitInvalid callback to focus the first element with aria-invalid="true", or an error summary when there are several errors.

How do async validators avoid races in TanStack Form?

Async validators are debounced with asyncDebounceMs and receive an AbortSignal that is aborted when a newer run starts. Pass the signal to fetch so stale requests are cancelled.

← Back to Framework Integration Patterns

Explore This Section