Form-Level Validation
Field-level validators are great for rules that belong to a single field — is this email formatted correctly, is this field empty, is this username taken. But some rules don't belong to any one field. They only make sense when you look at multiple fields together.
That's what form-level validation is for.
When to Use Form-Level Validation
The clearest signal that you need form-level validation is when your rule requires reading more than one field value. Common cases:
- Password confirmation — the
confirmPasswordfield must matchpassword - Date ranges — the end date must come after the start date
- At least one required — the user must fill in a phone number, email, or mailing address, but any one of them is fine
These rules can't live on a single field because they need context from the whole form. Putting them on useForm instead of form.Field is the right call.
validators on useForm
useForm accepts a validators object — the same event-keyed structure as field validators, but the function receives { value } where value is the entire form's current values.
const form = useForm({
defaultValues: {
password: '',
confirmPassword: '',
},
validators: {
onSubmit: ({ value }) => {
if (value.password !== value.confirmPassword) {
return 'Passwords do not match'
}
return undefined
},
},
onSubmit: async ({ value }) => {
console.log(value)
},
})
Return a string to set an error, return undefined to clear it. The same timing keys work here — onChange, onBlur, onSubmit — but onSubmit is the most natural fit for cross-field rules. You don't usually need to recheck "do these passwords match" on every keystroke.
Form-Level vs Field-Level Errors
These are separate. Field-level errors live in field.state.meta.errors and are only accessible inside that field's render function. Form-level errors live in form.state.errors and are accessible anywhere.
To display form-level errors, use form.Subscribe:
<form.Subscribe
selector={(state) => state.errors}
children={(errors) =>
errors.length > 0 ? (
<div style={{ color: 'red' }}>
{errors.join(', ')}
</div>
) : null
}
/>
Put this wherever makes sense in your form layout — typically right before the submit button, so it's visible when the user tries to submit.
Combining Form-Level and Field-Level
The two layers complement each other. Field validators handle individual field rules. Form validators handle cross-field rules. You don't have to choose one or the other.
A password field might use a field-level onChange validator to enforce a minimum length while the user is typing. The form itself adds an onSubmit validator to confirm both passwords match. Both can produce errors at the same time — they don't interfere with each other.
Complete Example
Here's a password change form that uses both layers:
import { useForm } from '@tanstack/react-form'
function ChangePasswordForm() {
const form = useForm({
defaultValues: {
password: '',
confirmPassword: '',
},
validators: {
onSubmit: ({ value }) => {
if (value.password !== value.confirmPassword) {
return 'Passwords do not match'
}
return undefined
},
},
onSubmit: async ({ value }) => {
console.log('Changing password:', value)
},
})
return (
<form
onSubmit={(e) => {
e.preventDefault()
form.handleSubmit()
}}
>
<form.Field
name="password"
validators={{
onChange: ({ value }) =>
value.length < 8 ? 'Password must be at least 8 characters' : undefined,
}}
children={(field) => (
<div>
<label htmlFor="password">New Password</label>
<input
id="password"
type="password"
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="confirmPassword"
validators={{
onBlur: ({ value }) =>
!value ? 'Please confirm your password' : undefined,
}}
children={(field) => (
<div>
<label htmlFor="confirmPassword">Confirm Password</label>
<input
id="confirmPassword"
type="password"
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.Subscribe
selector={(state) => state.errors}
children={(errors) =>
errors.length > 0 ? (
<div style={{ color: 'red' }}>
{errors.join(', ')}
</div>
) : null
}
/>
<button type="submit">Change Password</button>
</form>
)
}
The password field catches the minimum length issue while the user types. The confirmPassword field catches an empty submission on blur. The form-level validator fires on submit and catches the mismatch between both fields. Each layer does exactly one job.