Synchronous Field Validation
Now that you understand field state, it's time to put field.state.meta.errors to work. TanStack Form has a clean, declarative way to define validation: you pass a validators object to form.Field where each key is an event name and each value is a function that returns an error string or undefined.
The validators Prop
form.Field accepts a validators object. The keys are event names — onChange, onBlur, onSubmit — and the values are functions that receive { value, fieldApi } and return either a string (the error message) or undefined (no error).
That's the whole API. No schema libraries required, no special syntax — just functions that return strings.
onChange
onChange runs on every value change, meaning every keystroke. It's the most immediate feedback you can give a user.
validators={{
onChange: ({ value }) =>
value.length < 3 ? 'Must be at least 3 characters' : undefined,
}}
Use this for rules where instant feedback genuinely helps — like showing a character count requirement while someone is typing a username. Be careful not to overuse it. Showing errors before the user has had a chance to finish typing can feel aggressive.
onBlur
onBlur runs when the field loses focus. This is often the better default because it waits until the user signals they're done with the field.
validators={{
onBlur: ({ value }) =>
!value ? 'This field is required' : undefined,
}}
Required field checks are a natural fit here. There's no point telling someone a field is required while they're still thinking about what to type into it.
onSubmit
onSubmit runs only when the form is submitted. Think of it as the last line of defense — validation that only fires at the moment the user tries to send the data.
validators={{
onSubmit: ({ value }) =>
!value.includes('@') ? 'Must be a valid email' : undefined,
}}
This is useful for checks you don't want running on every blur, or for rules that only make sense in the context of the full form submission.
Combining Validators
You can use multiple event keys on the same field. Each one runs at its own time — they don't interfere with each other, and they each contribute independently to field.state.meta.errors.
validators={{
onChange: ({ value }) =>
value.length < 3 ? 'Must be at least 3 characters' : undefined,
onBlur: ({ value }) =>
!value ? 'First name is required' : undefined,
}}
Here, the blur check fires first (when the user leaves the field), and the change check fires from that point forward as they type. Both can produce errors simultaneously, and both will appear in the errors array.
Displaying Errors
Errors accumulate in field.state.meta.errors as an array. To display them:
{field.state.meta.errors.length > 0 && (
<p style={{ color: 'red' }}>{field.state.meta.errors.join(', ')}</p>
)}
If you want to hold off until the user has actually interacted with the field, gate the display on field.state.meta.isTouched as well. That pattern was covered in the previous lesson.
Complete Example
Here's a form with two fields, each using a combination of validators:
import { useForm } from '@tanstack/react-form'
function SignupForm() {
const form = useForm({
defaultValues: { firstName: '', email: '' },
onSubmit: async ({ value }) => {
console.log('Submitted:', value)
},
})
return (
<form
onSubmit={(e) => {
e.preventDefault()
form.handleSubmit()
}}
>
<form.Field
name="firstName"
validators={{
onChange: ({ value }) =>
value.length < 3 ? 'Must be at least 3 characters' : undefined,
onBlur: ({ value }) =>
!value ? 'First name is required' : undefined,
}}
children={(field) => (
<div>
<label htmlFor="firstName">First Name</label>
<input
id="firstName"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
{field.state.meta.errors.length > 0 && (
<p style={{ color: 'red' }}>{field.state.meta.errors.join(', ')}</p>
)}
</div>
)}
/>
<form.Field
name="email"
validators={{
onBlur: ({ value }) =>
!value ? 'Email is required' : undefined,
onSubmit: ({ value }) =>
!value.includes('@') ? 'Must be a valid email' : undefined,
}}
children={(field) => (
<div>
<label htmlFor="email">Email</label>
<input
id="email"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
{field.state.meta.errors.length > 0 && (
<p style={{ color: 'red' }}>{field.state.meta.errors.join(', ')}</p>
)}
</div>
)}
/>
<button type="submit">Sign Up</button>
</form>
)
}
The firstName field gives feedback while typing (once three characters is the threshold) and catches empty submissions on blur. The email field stays quiet during typing but catches a missing value on blur and a malformed address on submit. Each validator does one job at the right moment.
That covers synchronous validation. Once you need to check something against a server -- a taken username, a duplicate email -- you'll reach for async validators, which use the same event hooks but return promises instead.