Skip to main content

Async Validation Guide

Async validation lets you check a field against a server — "is this username taken?", "is this email already registered?" — while the user types, without blocking the form.

Basic async field validation

Add an onChangeAsync (or onBlurAsync) validator under fieldValidators. The validator receives a context object { value, values, fieldName } and returns an error message string when invalid, or undefined when valid.

const form = useForm({
defaultValues: { email: "" },
fieldValidators: {
email: {
onChangeAsync: async ({ value }) => {
if (!value) return undefined;

const response = await fetch(`/api/check-email?email=${value}`);
const { exists } = await response.json();

return exists ? "Email already taken" : undefined;
},
},
},
});
Return value

A field validator returns a string (the error message, attached to that field) or undefined (valid). Returning an object like { isValid, errors } does not work for field-level validators — use a plain string. (Form-level validators in validators can return { fields }; see below.)

Debouncing

You usually don't want to hit the server on every keystroke. Add asyncDebounceMs to wait until the user pauses:

const form = useForm({
defaultValues: { username: "" },
fieldValidators: {
username: {
onChangeAsync: async ({ value }) => {
if (!value) return undefined;
const res = await fetch(`/api/username-available?u=${value}`);
const { available } = await res.json();
return available ? undefined : "That username is taken";
},
asyncDebounceMs: 500, // wait 500ms after typing stops
},
},
});

You can scope the debounce to a specific event with onChangeAsyncDebounceMs / onBlurAsyncDebounceMs, or set asyncDebounceMs to cover all async validators on that field.

Debouncing synchronous validation

asyncDebounceMs only affects async validators. To debounce synchronous validation (e.g. an expensive schema, or just to reduce error flicker while the user types), use validationDebounceMs. It works at both the form and field level and defaults to 0 (validate on every change, unchanged):

const form = useForm({
validators: {
onChange: schema,
validationDebounceMs: 200, // coalesce sync validation while typing
},
});

Error clearing stays immediate — only the setting of a new error is delayed — so a field that becomes valid is never left showing a stale error during the quiet period.

Validate on blur instead of change

To check only when the user leaves the field, use onBlurAsync:

fieldValidators: {
email: {
onBlurAsync: async ({ value }) => {
const res = await fetch(`/api/check-email?email=${value}`);
const { exists } = await res.json();
return exists ? "Email already taken" : undefined;
},
},
}

Combining with schema validation

Async validators run alongside your schema. Use the schema for shape/format and the async validator for server checks:

import { z } from "zod";

const form = useForm({
defaultValues: { email: "" },
validators: {
onChange: z.object({ email: z.string().email("Invalid email") }),
},
fieldValidators: {
email: {
onChangeAsync: async ({ value }) => {
const res = await fetch(`/api/check-email?email=${value}`);
const { exists } = await res.json();
return exists ? "Email already taken" : undefined;
},
asyncDebounceMs: 500,
},
},
});

Reading async state in the UI

The result lands in formState.errors[field] like any other error, so you render it the same way:

<input {...register("email")} />
{form.formState.errors.email && (
<span className="error">{form.formState.errors.email}</span>
)}

With AutoForm

AutoForm accepts the same fieldValidators prop:

<AutoForm
schema={schema}
fieldValidators={{
email: {
onChangeAsync: async ({ value }) => {
const res = await fetch(`/api/check-email?email=${value}`);
const { exists } = await res.json();
return exists ? "Email already taken" : undefined;
},
asyncDebounceMs: 500,
},
}}
onSubmit={(data) => console.log(data)}
/>