When building a form, the first question isn't "how do I validate?" — it's "WHAT should I validate, WHERE, and WHEN?" This guide walks through those decisions with concrete patterns.
| Strategy | When | Best For |
|---|---|---|
onSubmit | Only when the form is submitted | Simple forms, login forms |
onBlur | When the user leaves a field | Registration forms, profile editing |
onChange | On every keystroke (debounced) | Search filters, real-time calculations |
onTouched | First validation on blur, then onChange | Complex forms (best UX for most cases) |
Is the form simple (< 5 fields)?
├── YES → onSubmit (don't annoy the user mid-typing)
└── NO → Does the form have expensive validation (API calls)?
├── YES → onBlur (validate when they're done with the field)
└── NO → onTouched (first validate on blur, then live updates)
React Hook Form config:
useForm({
mode: "onTouched", // ← recommended default
reValidateMode: "onChange", // Re-validate on change after first error
});| Layer | What | Example |
|---|---|---|
| Schema (Zod) | Data shape, types, formats | Email format, min/max length |
| Component | UI-specific rules | "Confirm password" match |
| Hook | Cross-field logic | Step validation in multi-step forms |
| Server | Business rules, uniqueness | Email availability, promo code validity |
┌─────────────────────────────────────┐
│ Server Validation │ ← Business rules, DB checks
├─────────────────────────────────────┤
│ Hook Validation │ ← Cross-field, multi-step
├─────────────────────────────────────┤
│ Schema Validation (Zod) │ ← Data shape, formats
├─────────────────────────────────────┤
│ HTML5 Validation (native) │ ← type="email", required
└─────────────────────────────────────┘
Rule: Validate at the lowest layer possible. Don't put format checks in your server when Zod can catch them client-side.
The simplest pattern — one schema per form, validated by React Hook Form:
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
useForm({ resolver: zodResolver(schema) });When one field depends on another (e.g., password confirmation):
const schema = z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).refine(
(data) => data.password === data.confirmPassword,
{
message: "Passwords don't match",
path: ["confirmPassword"], // Attach error to specific field
}
);When a field is only required based on another field's value:
const schema = z.object({
hasCompany: z.boolean(),
companyName: z.string().optional(),
}).refine(
(data) => !data.hasCompany || (data.companyName && data.companyName.length > 0),
{
message: "Company name is required",
path: ["companyName"],
}
);Clean the data before validating (e.g., strip spaces from card numbers):
const cardNumberSchema = z.string()
.transform(val => val.replace(/[\s-]/g, "")) // Strip spaces/dashes
.pipe(
z.string()
.regex(/^\d{13,19}$/)
.refine(luhnCheck, "Invalid card number")
);For uniqueness checks that require an API call:
const emailSchema = z.string()
.email()
.refine(async (email) => {
const res = await fetch(`/api/check-email?email=${email}`);
const data = await res.json();
return data.available;
}, "Email already registered");Important: Debounce async validation to avoid API spam. See src/schemas/async-validation.ts.
Validate each step independently before allowing navigation:
const stepSchemas = [
z.object({ name: z.string(), email: z.string().email() }), // Step 1
z.object({ street: z.string(), city: z.string() }), // Step 2
z.object({ cardNumber: z.string(), cvv: z.string() }), // Step 3
];
// Validate only the current step
const validateCurrentStep = () => stepSchemas[currentStep].safeParse(data);Show errors directly below the field:
<FormField label="Email" error={errors.email?.message}>
<input {...register("email")} />
</FormField>Show all errors in a list at the top of the form:
{Object.keys(errors).length > 0 && (
<div role="alert" className="bg-red-50 p-4 rounded mb-4">
<h3 className="font-bold text-red-800">Please fix the following:</h3>
<ul className="list-disc pl-5 mt-2">
{Object.entries(errors).map(([field, error]) => (
<li key={field}>{error?.message}</li>
))}
</ul>
</div>
)}For errors that happen during submission (not field validation):
const onSubmit = async (data) => {
try {
await api.register(data);
} catch (err) {
toast.error(err.message); // Show as a toast, not inline
}
};// BAD: fires validation 100ms after every character
mode: "onChange" // + expensive async validation
// GOOD: validate on blur, then onChange after first error
mode: "onTouched"// BAD: red errors everywhere on page load
useForm({ mode: "all" });
// GOOD: errors appear only after the user touches a field
useForm({ mode: "onTouched" });// BAD: freezing the form while checking email
setIsLoading(true);
await checkEmail(email); // 500ms+ API call
setIsLoading(false);
// GOOD: debounce + show inline indicator
const debouncedCheck = createDebouncedValidator(checkEmail, { delay: 500 });// BAD: validation logic in two places
const schema = z.object({ age: z.number().min(18) });
// Also in the component:
if (age < 18) setError("Must be 18+"); // WHY? The schema handles this!
// GOOD: single source of truth (the schema)
const schema = z.object({ age: z.number().min(18, "Must be 18+") });Every form must meet these requirements:
(via htmlFor or wrapping)role="alert" or aria-live="polite")aria-invalid="true"aria-describedbyaria-required)*This guide is part of the Form Validation Library.*
Part of Frontend Developer Pro
Get the full Form Validation Library and unlock everything.
Get the complete guide with every chapter unlocked, including code samples, diagrams, and best practices.
Access all interactive tools with complete data, all workload profiles, and the full scenario library.
Downloadable source code, configuration files, and working examples from every chapter.
Free updates for life. Every new chapter, tool, and improvement included.