TanStack Form provides highly customizable validation with control over when and how validation occurs.
Validation Features
- Control when validation runs (onChange, onBlur, onSubmit)
- Define validators at the field level or form level
- Support for synchronous and asynchronous validation
- Built-in debouncing for async validators
- Integration with schema libraries (Zod, Valibot, ArkType, Effect/Schema)
When Does Validation Run?
You control when validation occurs by providing validator callbacks. The field() method accepts validators like onChange, onBlur, onSubmit, etc.
Validating on Change
Validate as the user types:
import { html, nothing } from 'lit'
${this.#form.field(
{
name: 'age',
validators: {
onChange: ({ value }) =>
value < 13 ? 'You must be 13 to make an account' : undefined,
},
},
(field) => {
return html`
<label for="${field.name}">Age:</label>
<input
id="${field.name}"
name="${field.name}"
.value="${field.state.value}"
type="number"
@input="${(e: Event) => {
const target = e.target as HTMLInputElement
field.handleChange(target.valueAsNumber)
}}"
/>
${!field.state.meta.isValid
? html`<em role="alert">${field.state.meta.errors.join(', ')}</em>`
: nothing}
`
},
)}
Validating on Blur
Validate when the field loses focus:
import { html, nothing } from 'lit'
${this.#form.field(
{
name: 'age',
validators: {
onBlur: ({ value }) =>
value < 13 ? 'You must be 13 to make an account' : undefined,
},
},
(field) => {
return html`
<label for="${field.name}">Age:</label>
<input
id="${field.name}"
name="${field.name}"
.value="${field.state.value}"
type="number"
@blur="${() => field.handleBlur()}"
@input="${(e: Event) => {
const target = e.target as HTMLInputElement
field.handleChange(target.valueAsNumber)
}}"
/>
${!field.state.meta.isValid
? html`<em role="alert">${field.state.meta.errors.join(', ')}</em>`
: nothing}
`
},
)}
Multiple Validators
Combine different validators for comprehensive validation:
${this.#form.field(
{
name: 'age',
validators: {
onChange: ({ value }) =>
value < 13 ? 'You must be 13 to make an account' : undefined,
onBlur: ({ value }) => (value < 0 ? 'Invalid value' : undefined),
},
},
(field) => {
return html`
<label for="${field.name}">Age:</label>
<input
id="${field.name}"
name="${field.name}"
.value="${field.state.value}"
type="number"
@blur="${() => field.handleBlur()}"
@input="${(e: Event) => {
const target = e.target as HTMLInputElement
field.handleChange(target.valueAsNumber)
}}"
/>
${!field.state.meta.isValid
? html`<em role="alert">${field.state.meta.errors.join(', ')}</em>`
: nothing}
`
},
)}
Displaying Errors
Using the Errors Array
Display all errors for a field:
${!field.state.meta.isValid
? html`<em>${field.state.meta.errors.join(',')}</em>`
: nothing}
Using the Error Map
Access specific errors by validator type:
${field.state.meta.errorMap['onChange']
? html`<em>${field.state.meta.errorMap['onChange']}</em>`
: nothing}
Typed Error Objects
Validators can return custom error objects:
import { html, nothing } from 'lit'
${this.#form.field(
{
name: 'age',
validators: {
onChange: ({ value }) => (value < 13 ? { isOldEnough: false } : undefined),
},
},
(field) => {
return html`
<!-- errorMap.onChange is type `{isOldEnough: false} | undefined` -->
${!field.state.meta.errorMap['onChange']?.isOldEnough
? html`<em>The user is not old enough</em>`
: nothing}
`
},
)}
Define validators for the entire form in the TanStackFormController constructor:
import { LitElement, html, nothing } from 'lit'
import { customElement } from 'lit/decorators.js'
import { TanStackFormController } from '@tanstack/lit-form'
@customElement('my-form')
export class MyForm extends LitElement {
#form = new TanStackFormController(this, {
defaultValues: {
age: 0,
},
onSubmit: async ({ value }) => {
console.log(value)
},
validators: {
onChange({ value }) {
if (value.age < 13) {
return 'Must be 13 or older to sign'
}
return undefined
},
},
})
render() {
return html`
<div>
${this.#form.api.state.errorMap.onChange
? html`
<div>
<em>
There was an error on the form:
${this.#form.api.state.errorMap.onChange}
</em>
</div>
`
: nothing}
</div>
`
}
}
You can set errors on specific fields from form-level validators:
#form = new TanStackFormController(this, {
defaultValues: {
age: 0,
socials: [],
details: {
email: '',
},
},
validators: {
onSubmitAsync: async ({ value }) => {
const hasErrors = await verifyDataOnServer(value)
if (hasErrors) {
return {
form: 'Invalid data',
fields: {
age: 'Must be 13 or older to sign',
'socials[0].url': 'The provided URL does not exist',
'details.email': 'An email is required',
},
}
}
return null
},
},
})
Field-level validation takes precedence over form-level validation for the same field. If both return errors, only the field-level error will be displayed.
Asynchronous Validation
For validation that requires network calls or other async operations, use async validators:
${this.#form.field(
{
name: 'age',
validators: {
onChangeAsync: async ({ value }) => {
await new Promise((resolve) => setTimeout(resolve, 1000))
return value < 13 ? 'You must be 13 to make an account' : undefined
},
},
},
(field) => {
return html`
<label for="${field.name}">Age:</label>
<input
id="${field.name}"
name="${field.name}"
.value="${field.state.value}"
type="number"
@input="${(e: Event) => {
const target = e.target as HTMLInputElement
field.handleChange(target.valueAsNumber)
}}"
/>
${!field.state.meta.isValid
? html`<em role="alert">${field.state.meta.errors.join(', ')}</em>`
: nothing}
`
},
)}
Combining Sync and Async Validation
You can use both synchronous and asynchronous validators together:
${this.#form.field(
{
name: 'age',
validators: {
onBlur: ({ value }) =>
value < 13 ? 'You must be at least 13' : undefined,
onBlurAsync: async ({ value }) => {
const currentAge = await fetchCurrentAgeOnProfile()
return value < currentAge ? 'You can only increase the age' : undefined
},
},
},
(field) => {
return html`<!-- ... -->`
},
)}
By default, the async validator only runs if the sync validator succeeds. Set asyncAlways: true to change this behavior.
Built-in Debouncing
Prevent excessive API calls by debouncing async validators:
${this.#form.field(
{
name: 'age',
asyncDebounceMs: 500,
validators: {
onChangeAsync: async ({ value }) => {
// Only runs after 500ms of no changes
},
},
},
(field) => {
return html`<!-- ... -->`
},
)}
You can override debounce timing per validator:
${this.#form.field(
{
name: 'age',
asyncDebounceMs: 500,
validators: {
onChangeAsyncDebounceMs: 1500,
onChangeAsync: async ({ value }) => {
// Runs after 1500ms
},
onBlurAsync: async ({ value }) => {
// Runs after 500ms
},
},
},
(field) => {
return html`<!-- ... -->`
},
)}
Schema Validation
TanStack Form supports all Standard Schema libraries:
Define a schema for the entire form:
import { z } from 'zod'
import { LitElement, html } from 'lit'
import { customElement } from 'lit/decorators.js'
import { TanStackFormController } from '@tanstack/lit-form'
const userSchema = z.object({
age: z.number().gte(13, 'You must be 13 to make an account'),
})
@customElement('my-form')
export class MyForm extends LitElement {
#form = new TanStackFormController(this, {
defaultValues: {
age: 0,
},
validators: {
onChange: userSchema,
},
})
render() {
return html`
<div>
${this.#form.field({ name: 'age' }, (field) => {
return html`<!-- ... -->`
})}
</div>
`
}
}
Field-Level Schema
Use schemas directly in field validators:
import { html } from 'lit'
import { z } from 'zod'
${this.#form.field(
{
name: 'age',
validators: {
onChange: z.number().gte(13, 'You must be 13 to make an account'),
},
},
(field) => {
return html`<!-- ... -->`
},
)}
Async Schema Validation
Schemas work with async validators too:
import { html } from 'lit'
import { z } from 'zod'
${this.#form.field(
{
name: 'age',
validators: {
onChange: z.number().gte(13, 'You must be 13 to make an account'),
onChangeAsyncDebounceMs: 500,
onChangeAsync: z.number().refine(
async (value) => {
const currentAge = await fetchCurrentAgeOnProfile()
return value >= currentAge
},
{
message: 'You can only increase the age',
},
),
},
},
(field) => {
return html`<!-- ... -->`
},
)}
Complete Schema Example
import { LitElement, html, nothing } from 'lit'
import { customElement } from 'lit/decorators.js'
import { TanStackFormController } from '@tanstack/lit-form'
import { repeat } from 'lit/directives/repeat.js'
import { z } from 'zod'
const FormSchema = z.object({
firstName: z
.string()
.min(3, 'You must have a length of at least 3')
.startsWith('A', "First name must start with 'A'"),
lastName: z.string().min(3, 'You must have a length of at least 3'),
})
@customElement('tanstack-form-demo')
export class TanStackFormDemo extends LitElement {
#form = new TanStackFormController(this, {
defaultValues: {
firstName: '',
lastName: '',
},
validators: {
onChange: FormSchema,
},
onSubmit({ value }) {
console.log(value)
},
})
render() {
return html`
<form
@submit=${(e: Event) => {
e.preventDefault()
e.stopPropagation()
this.#form.api.handleSubmit()
}}
>
${this.#form.field({ name: 'firstName' }, (field) => {
return html`
<div>
<label for="${field.name}">First Name:</label>
<input
id="${field.name}"
name="${field.name}"
.value="${field.state.value}"
@blur="${() => field.handleBlur()}"
@input="${(e: Event) => {
const target = e.target as HTMLInputElement
field.handleChange(target.value)
}}"
/>
${field.state.meta.isTouched && !field.state.meta.isValid
? html`${repeat(
field.state.meta.errors,
(__, idx) => idx,
(error) => {
return html`<div style="color: red;">${error?.message}</div>`
},
)}`
: nothing}
</div>
`
})}
<button type="submit" ?disabled=${this.#form.api.state.isSubmitting}>
${this.#form.api.state.isSubmitting ? '...' : 'Submit'}
</button>
</form>
`
}
}
Preventing Invalid Submissions
Use the canSubmit flag to control form submission:
render() {
return html`
<button type="submit" ?disabled="${!this.#form.api.state.canSubmit}">
${this.#form.api.state.isSubmitting ? '...' : 'Submit'}
</button>
`
}
The canSubmit flag is false when:
- Any field has validation errors
- The form has been touched by the user
To prevent submission before any interaction, combine flags:
const cannotSubmit = !this.#form.api.state.canSubmit || this.#form.api.state.isPristine
Next Steps