TanStack Form + Zod Validation in React (2026)
June 22, 2026
To validate a TanStack Form with Zod, pass the Zod schema straight into a validator — validators: { onChange: schema } — on the useForm hook. TanStack Form v1 supports Standard Schema natively, so you no longer install or import a Zod adapter. The schema validates and infers your form's types at once.1
TL;DR
TanStack Form v1.33.0 speaks Standard Schema, so Zod (and Valibot, ArkType, or Effect/Schema) plug in directly — the old @tanstack/zod-form-adapter and zodValidator are no longer needed.1 This hands-on guide builds a real React form from an empty component: a useForm instance validated by a Zod 4 schema, per-field error rendering, debounced async validation, dynamic array fields, and a submit button wired to canSubmit. Every snippet was type-checked with tsc --noEmit against @tanstack/react-form@1.33.0, zod@4.4.3, and React 19 on 22 June 2026.
What you'll learn
- How to validate a TanStack Form with Zod (no adapter, just the schema)
- How to set up
useFormand aform.Fieldin React from scratch - How to show validation errors correctly (v1 errors are objects, not strings)
- How to add debounced async validation at the field level
- How to handle dynamic array fields with
mode="array" - When to reach for TanStack Form instead of React Hook Form
How do I validate a TanStack Form with Zod?
Pass the Zod schema directly to a validators key on useForm. There is no adapter step in v1 — TanStack Form treats any Standard Schema library, Zod included, as a first-class validator and uses it to both validate values and type the form.1
Install the two packages (TanStack Form pulls in its own core; you only add Zod):
npm install @tanstack/react-form@1.33.0 zod@4.4.3
Now define a schema and feed it to the form. Note the Zod 4 idiom z.email() — the top-level helper that replaces the older z.string().email() chain:
import { useForm } from '@tanstack/react-form'
import { z } from 'zod'
const schema = z.object({
email: z.email('Enter a valid email'),
password: z.string().min(8, 'At least 8 characters'),
})
export function SignupForm() {
const form = useForm({
defaultValues: { email: '', password: '' },
validators: { onChange: schema },
onSubmit: async ({ value }) => {
// value is fully typed: { email: string; password: string }
console.log('submit', value)
},
})
return (
<form
onSubmit={(e) => {
e.preventDefault()
e.stopPropagation()
form.handleSubmit()
}}
>
{/* fields go here */}
</form>
)
}
validators.onChange runs the schema on every change; swap it for onBlur or onSubmit to validate later. Because the schema also describes the shape, value inside onSubmit is typed as { email: string; password: string } with no extra generics.2
How do I set up a form.Field in React?
form.Field is a render-prop component: you give it a name and a function that receives the field's state and handlers. This keeps each input subscribed only to its own slice of form state, so typing in one field doesn't re-render the others.2
Drop a field inside the <form> from the previous section:
<form.Field name="email">
{(field) => (
<div>
<label htmlFor={field.name}>Email</label>
<input
id={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
</div>
)}
</form.Field>
The three pieces you wire on every input are field.state.value (the current value), field.handleChange (update it), and field.handleBlur (mark it touched). The name is type-checked against your defaultValues, so a typo like name="emial" is a compile error, not a silent runtime bug.
How do I show validation errors in TanStack Form?
Read field.state.meta.errors — but in v1 each error from a Standard Schema validator is an issue object, so render error.message, not the raw value. This is a common mistake when migrating from older adapter-based examples, which exposed errors as plain strings.1
<form.Field name="email">
{(field) => (
<div>
<input
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
{!field.state.meta.isValid && (
<em role="alert">
{field.state.meta.errors.map((err) => err?.message).join(', ')}
</em>
)}
</div>
)}
</form.Field>
Use field.state.meta.isValid to decide whether to show the message, and map over errors with err?.message because the array holds objects. (If you instead write a plain function validator that returns a string, that string is the error directly — see the next section. The two validator styles produce two different error shapes, which trips people up.)
How do I disable the submit button until the form is valid?
Wrap the button in form.Subscribe and select canSubmit. form.Subscribe re-renders only when the slice you select changes, so the button reacts to validity and submission state without re-rendering the whole form.2
<form.Subscribe selector={(state) => [state.canSubmit, state.isSubmitting]}>
{([canSubmit, isSubmitting]) => (
<button type="submit" disabled={!canSubmit}>
{isSubmitting ? 'Submitting…' : 'Create account'}
</button>
)}
</form.Subscribe>
canSubmit reflects whether the form can be submitted — it turns false once a validator is failing or an async check is still running — and isSubmitting is true while your onSubmit promise resolves, enough to disable the button and show a spinner without tracking that state yourself.
How do I add async validation in TanStack Form?
Add an onChangeAsync validator (plus onChangeAsyncDebounceMs) at the field level for checks that hit the network, like "is this username taken?" Async validators can be debounced — set onChangeAsyncDebounceMs so you don't fire a request on every keystroke — and they run only after the synchronous validators pass.1
<form.Field
name="username"
validators={{
onChange: ({ value }) =>
value.length < 3 ? 'Too short' : undefined,
onChangeAsyncDebounceMs: 500,
onChangeAsync: async ({ value }) => {
const res = await fetch(`/api/username-available?u=${value}`)
const { available } = await res.json()
return available ? undefined : 'Username is taken'
},
}}
>
{(field) => (
<div>
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
{field.state.meta.isValidating && <span>Checking…</span>}
{!field.state.meta.isValid && (
<em>{field.state.meta.errors.join(', ')}</em>
)}
</div>
)}
</form.Field>
Here the validators are plain functions that return a string (or undefined when valid), so errors holds strings and you render them directly — no .message. field.state.meta.isValidating is true while the debounced async check is in flight, which is what you bind a "Checking…" indicator to.
How do I handle array fields in TanStack Form?
Give the parent form.Field mode="array", then render one child field per item using an indexed name like guests[${i}].name. Add and remove rows with field.pushValue() and field.removeValue() — the list re-renders itself.3
<form.Field name="guests" mode="array">
{(field) => (
<div>
{field.state.value.map((_, i) => (
<form.Field key={i} name={`guests[${i}].name`}>
{(sub) => (
<input
value={sub.state.value}
onChange={(e) => sub.handleChange(e.target.value)}
/>
)}
</form.Field>
))}
<button type="button" onClick={() => field.pushValue({ name: '' })}>
Add guest
</button>
<button
type="button"
onClick={() => field.removeValue(field.state.value.length - 1)}
>
Remove last
</button>
</div>
)}
</form.Field>
A Zod schema like z.array(z.object({ name: z.string().min(1) })).min(1) validates the whole array at the form level, so "add at least one guest" and "every guest needs a name" both come from the same schema you already wrote.
When should I use TanStack Form instead of React Hook Form?
Choose TanStack Form when you want type-safe field paths, built-in async validation, and one mental model shared with TanStack Query or Router; stay on React Hook Form when you have simple-to-moderate forms and value its maturity and large ecosystem.4 Neither is universally "better" — they make different tradeoffs.
React Hook Form is ref-based and uncontrolled, which keeps re-renders low and the API small, and it has years of adoption and integrations behind it. TanStack Form uses a reactive store with fine-grained subscriptions, fully typed field names, a framework-agnostic core (React, Vue, Solid, Svelte, Angular), and Standard Schema validation in the box.4 If your forms are working and you aren't hitting type-safety or async-validation pain, there's no urgency to switch.
Bottom line
TanStack Form v1 makes Zod a one-line validator: pass the schema, render err.message, and let canSubmit drive the button — no adapter, no glue code. Build the synchronous form first, then layer in debounced async checks and array fields as your form grows. If you work across the TanStack ecosystem, see our TanStack Query prefetch guide for the Next.js App Router and our walkthrough of type-safe search params with TanStack Router and Zod. For another take on form submission and pending UI, compare this with React 19 server actions and optimistic UI.
Footnotes
-
TanStack Form — Form and Field Validation (React). https://tanstack.com/form/latest/docs/framework/react/guides/validation ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9
-
TanStack Form — Quick Start (React). https://tanstack.com/form/latest/docs/framework/react/quick-start ↩ ↩2 ↩3
-
TanStack Form — Arrays (React). https://tanstack.com/form/v1/docs/framework/react/guides/arrays ↩ ↩2
-
TanStack Form — Comparison. https://tanstack.com/form/latest/docs/comparison ↩ ↩2