Form Validation Patterns
TanStack Form validation patterns with FormShell. Every form uses FormShell to ensure browser-native validation never intercepts TanStack validators.
<form> with <input type="email"> triggers browser-native validation tooltips before TanStack Form validators fire. No aria-invalid gets set, no error messages render in the DOM, and E2E tests see nothing. FormShell applies noValidate automatically.Required Field (onSubmit)
Validates on submit. The error appears in the DOM as a visible message with aria-invalid on the input.
const form = useForm({
defaultValues: { name: '' },
validators: {
onSubmit: ({ value }) => {
if (!value.name.trim())
return { fields: { name: 'Name is required' } };
return undefined;
},
},
});
<FormShell form={form}>
<FormInput form={form} name="name" label="Full Name" required />
<Button type="submit">Submit</Button>
</FormShell>Email Format Validation
Validates email format on submit. With FormShell, the browser tooltip never appears — TanStack handles it.
validators: {
onSubmit: ({ value }) => {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.email)) {
return { fields: { email: 'Enter a valid email address' } };
}
return undefined;
},
}Live Validation (onChange)
Validates as the user types. Use for character limits, format checks, or immediate feedback.
<FormTextarea
form={form}
name="bio"
label="Bio"
validators={{
onChange: ({ value }) => {
if (value.length > 0 && value.length < 10)
return 'Bio must be at least 10 characters';
return undefined;
},
}}
/>Cross-Field Validation (Confirm Password)
Form-level onSubmit validator compares two fields. Errors target specific fields via the { fields: { ... } } return shape.
const form = useForm({
defaultValues: { password: '', confirmPassword: '' },
validators: {
onSubmit: ({ value }) => {
const errors: Record<string, string> = {};
if (value.password.length < 8)
errors.password = 'Password must be at least 8 characters';
if (value.password !== value.confirmPassword)
errors.confirmPassword = 'Passwords do not match';
return Object.keys(errors).length > 0
? { fields: errors }
: undefined;
},
},
});
<FormShell form={form}>
<FormInput form={form} name="password" type="password" label="Password" />
<FormInput form={form} name="confirmPassword" type="password" label="Confirm" />
<Button type="submit">Submit</Button>
</FormShell>Async Validation (Debounced)
Field-level onChangeAsync with onChangeAsyncDebounceMs. Fires after the user stops typing. Use for uniqueness checks against an API.
<FormInput
form={form}
name="username"
label="Username"
validators={{
onChangeAsyncDebounceMs: 500,
onChangeAsync: async ({ value }) => {
const res = await fetch(`/api/check-username?q=${value}`);
const { available } = await res.json();
return available ? undefined : `"${value}" is already taken`;
},
}}
/>Anti-Pattern: Raw <form> Without noValidate
Never do this:
// BAD — browser intercepts validation
<form action={() => form.handleSubmit()}>
<input type="email" ... />
</form>
// GOOD — TanStack Form handles validation
<FormShell form={form}>
<FormInput type="email" ... />
</FormShell>Without noValidate, the browser shows its own tooltip for type="email" and blocks TanStack Form's onSubmit validators from firing. Playwright cannot see browser tooltips — the test sees no error.