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

Form Validation Library v1.0.0 — Free Preview