Contents

Chapter 1

Validation Patterns Decision Guide

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.

When to Validate

StrategyWhenBest For
onSubmitOnly when the form is submittedSimple forms, login forms
onBlurWhen the user leaves a fieldRegistration forms, profile editing
onChangeOn every keystroke (debounced)Search filters, real-time calculations
onTouchedFirst validation on blur, then onChangeComplex forms (best UX for most cases)

Decision: Which Mode to Use

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:

tsx
useForm({
  mode: "onTouched",      // ← recommended default
  reValidateMode: "onChange",  // Re-validate on change after first error
});

Where to Validate

LayerWhatExample
Schema (Zod)Data shape, types, formatsEmail format, min/max length
ComponentUI-specific rules"Confirm password" match
HookCross-field logicStep validation in multi-step forms
ServerBusiness rules, uniquenessEmail availability, promo code validity

The Validation Layer Cake

┌─────────────────────────────────────┐
│          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.

Common Patterns

Pattern 1: Field-Level Schema

The simplest pattern — one schema per form, validated by React Hook Form:

tsx
const schema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});

useForm({ resolver: zodResolver(schema) });

Pattern 2: Cross-Field Validation

When one field depends on another (e.g., password confirmation):

tsx
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
  }
);

Pattern 3: Conditional Validation

When a field is only required based on another field's value:

tsx
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"],
  }
);

Pattern 4: Transform + Validate

Clean the data before validating (e.g., strip spaces from card numbers):

tsx
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")
  );

Pattern 5: Async Validation (Server Check)

For uniqueness checks that require an API call:

tsx
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.

Pattern 6: Multi-Step Validation

Validate each step independently before allowing navigation:

tsx
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);

Error Display Patterns

Inline Errors (Most Common)

Show errors directly below the field:

tsx
<FormField label="Email" error={errors.email?.message}>
  <input {...register("email")} />
</FormField>

Summary Errors (Forms with Many Fields)

Show all errors in a list at the top of the form:

tsx
{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>
)}

Toast Errors (Async/Server Errors)

For errors that happen during submission (not field validation):

tsx
const onSubmit = async (data) => {
  try {
    await api.register(data);
  } catch (err) {
    toast.error(err.message);  // Show as a toast, not inline
  }
};

Anti-Patterns to Avoid

1. Validating on Every Keystroke Without Debounce

tsx
// BAD: fires validation 100ms after every character
mode: "onChange"  // + expensive async validation

// GOOD: validate on blur, then onChange after first error
mode: "onTouched"

2. Showing All Errors Before the User Interacts

tsx
// BAD: red errors everywhere on page load
useForm({ mode: "all" });

// GOOD: errors appear only after the user touches a field
useForm({ mode: "onTouched" });

3. Blocking the UI During Async Validation

tsx
// 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 });

4. Duplicating Validation in Schema AND Component

tsx
// 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+") });

Accessibility Checklist

Every form must meet these requirements:

  • [ ] Every input has an associated (via htmlFor or wrapping)
  • [ ] Error messages are announced to screen readers (role="alert" or aria-live="polite")
  • [ ] Invalid fields have aria-invalid="true"
  • [ ] Error messages are linked via aria-describedby
  • [ ] Required fields are indicated (visually AND via aria-required)
  • [ ] Error messages are descriptive ("Please enter a valid email" not "Invalid")
  • [ ] Focus moves to the first error on failed submission
  • [ ] The form is keyboard-navigable (Tab order makes sense)

*This guide is part of the Form Validation Library.*

Part of Frontend Developer Pro

Chapter 2
🔒 Available in full product

Validation Patterns Decision Guide

You’ve reached the end of the free preview

Get the full Form Validation Library and unlock everything.

All Chapters

Get the complete guide with every chapter unlocked, including code samples, diagrams, and best practices.

Full Tool Suite

Access all interactive tools with complete data, all workload profiles, and the full scenario library.

Source Files

Downloadable source code, configuration files, and working examples from every chapter.

Lifetime Updates

Free updates for life. Every new chapter, tool, and improvement included.

Buy Now — $19 →
📦 Free sample included — download another copy or visit the store for the full product.
Form Validation Library v1.0.0 — Free Preview