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