React Hook Form + Zod Validation

Frontend Engineering intermediate Building with AI ~20 min read

Overview

React Hook Form (RHF) is a form library built around uncontrolled components and refs, which means form inputs manage their own DOM state rather than being driven by React state on every keystroke. This design eliminates the re-render cascade that plagues controlled-component form libraries — a form with 30 fields re-renders zero components when the user types into one of them, because RHF reads values directly from the DOM via ref callbacks. Validation, error display, and submission are orchestrated through a single useForm hook that returns register functions, error objects, and a submit handler.

Zod is a TypeScript-first schema declaration and validation library. You define a schema like z.object({ email: z.string().email(), age: z.number().min(18) }) and Zod gives you both runtime validation and a TypeScript type via z.infer<typeof schema>. This eliminates the common drift between your validation rules and your TypeScript types — the schema is the single source of truth for both. When paired with React Hook Form via the @hookform/resolvers package, Zod schemas become the validation engine: RHF delegates all validation to Zod, and Zod returns structured error messages that RHF maps onto the corresponding form fields.

In the platform and its component library, this combination powers every user-facing form: search configuration panels, API key management forms, merchant onboarding wizards, and settings pages. The pattern is consistent — define a Zod schema, infer the TypeScript type, pass the schema to useForm via the Zod resolver, and bind inputs with register. The result is forms that are type-safe from schema to submission handler, validate without custom logic, and re-render only when they need to.

Core Concepts

Uncontrolled Components with Refs

React Hook Form registers each input by attaching a ref callback, letting the browser DOM hold the input’s value. When the form submits, RHF reads all values from the DOM in a single pass. This avoids the “setState on every keystroke” pattern that causes O(n) re-renders per character typed, where n is the number of fields. The register function returns { onChange, onBlur, ref, name } — spread these onto an input and it’s wired up with zero state management code.

Zod Schema as Single Source of Truth

A Zod schema defines the shape, types, and constraints of your data in one declaration. z.infer<typeof schema> extracts the TypeScript type, so your form’s type and validation rules can never diverge. Schemas compose — you can extend, merge, pick, and omit fields from existing schemas to build form-specific variants without duplicating validation logic. Refinements (.refine()) handle cross-field validation like “confirm password must match password.”

Resolver Pattern

The @hookform/resolvers package provides adapter functions that connect validation libraries (Zod, Yup, Joi, etc.) to React Hook Form’s validation interface. The Zod resolver calls schema.safeParse(formValues) on every validation trigger and transforms Zod’s error format into RHF’s { fieldName: { message, type } } structure. This separation means RHF doesn’t know about Zod and Zod doesn’t know about RHF — the resolver is the bridge.

Validation Modes

RHF supports multiple validation trigger modes: onSubmit (default — validate only when the form is submitted), onBlur (validate when a field loses focus), onChange (validate on every change — use sparingly), and onTouched (validate on blur for untouched fields, then on change after first touch). The mode option on useForm controls this. For most forms, onBlur or onTouched provides the best UX — errors appear after the user finishes typing, not while they’re still composing input.

FormProvider and useFormContext

For forms split across multiple components (wizard steps, nested fieldsets), RHF provides FormProvider — a React context wrapper that makes the form instance available to any descendant via useFormContext(). Child components can call register, read errors, or watch values without prop drilling. This pattern is essential for complex forms where inputs live in separate components or tabs but belong to a single form submission.

How It Works

Defining the Zod Schema

The first step is defining a Zod schema that describes every field in your form, its type, and its constraints. Zod’s chain API makes this declarative — each method in the chain adds a constraint and an error message.

import { z } from "zod";

const searchConfigSchema = z.object({
  indexName: z
    .string()
    .min(3, "Index name must be at least 3 characters")
    .max(64, "Index name must be at most 64 characters")
    .regex(/^[a-z0-9_]+$/, "Only lowercase letters, numbers, and underscores"),
  description: z.string().max(256).optional(),
  maxResults: z
    .number({ invalid_type_error: "Must be a number" })
    .int("Must be a whole number")
    .min(1, "At least 1 result")
    .max(100, "At most 100 results"),
  enableAI: z.boolean().default(false),
  aiModel: z.string().optional(),
  rankingWeights: z.object({
    textRelevance: z.number().min(0).max(1),
    popularity: z.number().min(0).max(1),
    recency: z.number().min(0).max(1),
  }),
}).refine(
  (data) => !data.enableAI || (data.enableAI && data.aiModel),
  { message: "AI model is required when AI is enabled", path: ["aiModel"] }
);

// TypeScript type is inferred automatically
type SearchConfig = z.infer<typeof searchConfigSchema>;

Wiring Up the Form

The useForm hook accepts a resolver option that connects Zod validation. Default values are typed against the inferred schema type, catching mismatches at compile time.

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

function SearchConfigForm({ onSave }: { onSave: (data: SearchConfig) => void }) {
  const {
    register,
    handleSubmit,
    watch,
    formState: { errors, isSubmitting, isDirty },
  } = useForm<SearchConfig>({
    resolver: zodResolver(searchConfigSchema),
    defaultValues: {
      indexName: "",
      maxResults: 10,
      enableAI: false,
      rankingWeights: { textRelevance: 0.5, popularity: 0.3, recency: 0.2 },
    },
    mode: "onTouched",
  });

  const enableAI = watch("enableAI");

  return (
    <form onSubmit={handleSubmit(onSave)}>
      <div>
        <label htmlFor="indexName">Index Name</label>
        <input id="indexName" {...register("indexName")} />
        {errors.indexName && <span className="error">{errors.indexName.message}</span>}
      </div>

      <div>
        <label htmlFor="maxResults">Max Results</label>
        <input
          id="maxResults"
          type="number"
          {...register("maxResults", { valueAsNumber: true })}
        />
        {errors.maxResults && <span className="error">{errors.maxResults.message}</span>}
      </div>

      <div>
        <label>
          <input type="checkbox" {...register("enableAI")} />
          Enable AI Responses
        </label>
      </div>

      {enableAI && (
        <div>
          <label htmlFor="aiModel">AI Model</label>
          <select id="aiModel" {...register("aiModel")}>
            <option value="">Select a model</option>
            <option value="claude-sonnet">Claude Sonnet</option>
            <option value="claude-haiku">Claude Haiku</option>
          </select>
          {errors.aiModel && <span className="error">{errors.aiModel.message}</span>}
        </div>
      )}

      <button type="submit" disabled={isSubmitting || !isDirty}>
        {isSubmitting ? "Saving..." : "Save Configuration"}
      </button>
    </form>
  );
}

Dynamic Field Arrays

For forms with repeatable sections — like adding multiple ranking rules or filter conditions — RHF provides useFieldArray. It manages an array of fields with append, remove, and move operations, while keeping the entire array validated against a Zod array schema.

import { useFieldArray } from "react-hook-form";

const filtersSchema = z.object({
  filters: z.array(
    z.object({
      field: z.string().min(1, "Field is required"),
      operator: z.enum(["eq", "gt", "lt", "contains"]),
      value: z.string().min(1, "Value is required"),
    })
  ).min(1, "At least one filter is required"),
});

function FilterBuilder() {
  const { control, register, handleSubmit, formState: { errors } } = useForm({
    resolver: zodResolver(filtersSchema),
    defaultValues: { filters: [{ field: "", operator: "eq", value: "" }] },
  });

  const { fields, append, remove } = useFieldArray({ control, name: "filters" });

  return (
    <form onSubmit={handleSubmit(console.log)}>
      {fields.map((field, index) => (
        <div key={field.id} className="filter-row">
          <input {...register(`filters.${index}.field`)} placeholder="Field" />
          <select {...register(`filters.${index}.operator`)}>
            <option value="eq">equals</option>
            <option value="gt">greater than</option>
            <option value="lt">less than</option>
            <option value="contains">contains</option>
          </select>
          <input {...register(`filters.${index}.value`)} placeholder="Value" />
          <button type="button" onClick={() => remove(index)}>Remove</button>
        </div>
      ))}
      <button type="button" onClick={() => append({ field: "", operator: "eq", value: "" })}>
        Add Filter
      </button>
      <button type="submit">Apply Filters</button>
    </form>
  );
}

Practical Applications

In the merchant dashboard, every form that accepts user input uses this RHF + Zod pattern. The search configuration panel — where merchants set up their search indexes, tune ranking weights, and configure AI answer generation — is a complex multi-section form with conditional fields (AI settings only appear when AI is enabled), nested objects (ranking weights), and cross-field validation (weights must sum to 1.0). The Zod schema enforces all of these constraints, and the TypeScript type inferred from it flows through to the API call that persists the configuration.

The internal component library provides reusable form field components (FormInput, FormSelect, FormCheckbox) that wrap Radix UI primitives with RHF’s useFormContext. These components automatically read registration and error state from the nearest FormProvider, so consuming code just renders &lt;FormInput name="email" label="Email" /> — no manual register call, no error display logic. The Zod schema and useForm call live in the parent form component, while the individual field components are completely generic.

API key management forms use Zod’s .transform() to normalize input — trimming whitespace, lowercasing emails, stripping dashes from UUIDs — so the data that reaches the submit handler is already clean. The transform runs after validation, so the user sees errors about the raw input (“email is invalid”) but the submit handler receives the normalized version.

Implementation Patterns

Schema Composition for Multi-Step Forms

Wizard-style forms define a schema per step and compose them into a full schema for final validation. Each step validates its own slice, and the final submit validates the merged object.

const step1Schema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
});

const step2Schema = z.object({
  plan: z.enum(["free", "pro", "enterprise"]),
  billingCycle: z.enum(["monthly", "annual"]),
});

const step3Schema = z.object({
  agreeToTerms: z.literal(true, {
    errorMap: () => ({ message: "You must agree to the terms" }),
  }),
});

// Full schema for final validation
const fullSchema = step1Schema.merge(step2Schema).merge(step3Schema);
type OnboardingData = z.infer<typeof fullSchema>;

// Each step uses its own schema as the resolver
function Step1() {
  const { register, handleSubmit } = useForm({
    resolver: zodResolver(step1Schema),
  });
  // ...
}

Server-Side Error Integration

When the server returns validation errors (e.g., “this email is already taken”), RHF’s setError method maps them onto specific fields so they display inline, indistinguishable from client-side validation errors.

const onSubmit = async (data: SearchConfig) => {
  try {
    await api.saveConfig(data);
  } catch (err) {
    if (err instanceof ApiValidationError) {
      // Map server errors onto form fields
      for (const [field, message] of Object.entries(err.fieldErrors)) {
        setError(field as keyof SearchConfig, {
          type: "server",
          message,
        });
      }
    }
  }
};

Common Pitfalls

Further Reading