Lesson 7

Asynchronous Validation

Synchronous validators are great for checking if a field is empty or matches a pattern, but some checks require hitting a server. Is this username taken? Does this email already have an account? You can't answer those questions without making a request.

onChangeAsync

TanStack Form has async versions of every validator. Instead of returning a string or undefined directly, you return a Promise that resolves to one of those values.

validators={{
  onChangeAsync: async ({ value }) => {
    await new Promise((r) => setTimeout(r, 500)) // simulate API call
    if (value === 'admin') return 'Username is already taken'
    return undefined
  },
}}

The field stays in a validating state until the Promise resolves. If it resolves with a string, that string becomes the error. If it resolves with undefined, the field is valid.

onChangeAsyncDebounceMs

There's a problem with onChangeAsync by itself: it fires on every keystroke. If a user types "johndoe", you're firing seven API requests before they've even finished. That's expensive and unnecessary.

The fix is onChangeAsyncDebounceMs. Set it to a number of milliseconds, and TanStack Form will wait that long after the user stops typing before running the async validator.

validators={{
  onChangeAsync: async ({ value }) => {
    const response = await fetch(`/api/check-username?q=${value}`)
    const { available } = await response.json()
    return available ? undefined : 'Username is already taken'
  },
  onChangeAsyncDebounceMs: 500,
}}

With this setup, the validator only fires once the user has paused typing for 500ms. Much better for performance, and much easier on your API.

onBlurAsync

Sometimes you don't want to validate while the user is typing at all. onBlurAsync runs the async validator when the field loses focus instead. This can be better UX for expensive checks — the user gets to finish their thought before you validate.

validators={{
  onBlurAsync: async ({ value }) => {
    const response = await fetch(`/api/check-email?q=${value}`)
    const { available } = await response.json()
    return available ? undefined : 'An account with this email already exists'
  },
}}

Showing a Loading State

While an async validator is running, field.state.meta.isValidating is true. Use it to show a spinner or a "Checking..." message so the user knows something is happening.

<form.Field name="username" validators={{ onChangeAsync: ..., onChangeAsyncDebounceMs: 500 }}>
  {(field) => (
    <div>
      <input value={field.state.value} onChange={(e) => field.handleChange(e.target.value)} />
      {field.state.meta.isValidating && <span>Checking availability...</span>}
      {field.state.meta.errors.length > 0 && (
        <span>{field.state.meta.errors.join(', ')}</span>
      )}
    </div>
  )}
</form.Field>

Complete Example

Here's a username field with debounced async validation and a loading indicator:

function SignupForm() {
  const form = useForm({
    defaultValues: { username: '' },
    onSubmit: async ({ value }) => console.log(value),
  })

  return (
    <form onSubmit={(e) => { e.preventDefault(); form.handleSubmit() }}>
      <form.Field
        name="username"
        validators={{
          onChangeAsync: async ({ value }) => {
            const response = await fetch(`/api/check-username?q=${value}`)
            const { available } = await response.json()
            return available ? undefined : 'Username is already taken'
          },
          onChangeAsyncDebounceMs: 500,
        }}
      >
        {(field) => (
          <div>
            <label>Username</label>
            <input
              value={field.state.value}
              onChange={(e) => field.handleChange(e.target.value)}
            />
            {field.state.meta.isValidating && <span>Checking...</span>}
            {field.state.meta.errors.length > 0 && (
              <span>{field.state.meta.errors.join(', ')}</span>
            )}
          </div>
        )}
      </form.Field>
      <button type="submit">Sign Up</button>
    </form>
  )
}

The 500ms debounce means the API only gets called after the user pauses. The isValidating flag gives instant feedback that something is happening. And once the check completes, either the error shows or the field clears.