Skip to main content
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}
    `
  },
)}

Form-Level Validation

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

Setting Field Errors from Form Validators

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:

Form-Level Schema

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

Build docs developers (and LLMs) love