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.