Type-Safe Forms with React Hook Form and Zod
Frontend14 min read

Type-Safe Forms with React Hook Form and Zod

A deep, practical guide to building fast, fully type-safe forms in React using React Hook Form and Zod. Learn schema inference, controlled inputs, field arrays, async and cross-field validation, server errors, and sharing one schema across client and server.

Taha Kocal

Taha Kocal

Full-Stack & Mobile Developer

May 18, 2026
#React Hook Form#Zod#TypeScript#Forms#Validation#Next.js#React

React Hook Form and Zod together form the standard approach to building type-safe forms in React: React Hook Form manages form state and rendering with minimal re-renders, while Zod validates the data against a schema and infers the TypeScript types automatically. React Hook Form relies on uncontrolled inputs and refs, so typing in one field does not re-render the whole form, and it ships with zero dependencies. Zod is a TypeScript-first validation library: you declare a schema once and get both a runtime validator and a static type via z.infer, eliminating duplicated validation logic. The two connect through the zodResolver adapter from @hookform/resolvers, which runs the schema on every submit and maps failures to field-level errors. This guide covers wiring them together, controlled inputs with Controller, array fields with useFieldArray, cross-field and async validation, server errors, and sharing one schema across client and server.

The core idea is simple: Zod owns the shape and rules of your data, RHF owns the form state and rendering, and TypeScript ties them together so the compiler catches mistakes long before your users do. You write the schema once and get inferred types, client validation, and (if you want) server validation from the same source of truth.

Why React Hook Form?

Most naive React form implementations store every input value in component state and update it on each change. That means every keystroke triggers a re-render of the form and often its children. React Hook Form takes the opposite approach: it leans on uncontrolled inputs and refs, subscribing to changes rather than driving them through React state. The result is that typing in one field does not re-render the entire form.

What RHF buys you compared to hand-rolled form state:

  • Minimal re-renders: inputs are registered via refs, so isolated changes stay isolated.
  • Tiny footprint with zero dependencies, which keeps your bundle lean.
  • A single source of form state (values, errors, dirty/touched fields, submit status) accessible through one hook.
  • First-class validation integration through resolvers, so you are not locked into a bespoke validation format.
  • Built-in support for async submission, focus management on errors, and reset semantics.

Why Zod?

Zod is a TypeScript-first schema declaration and validation library. You describe your data once and Zod gives you two things at the same time: a runtime validator and a static type. The runtime validator checks untrusted input (user forms, API payloads), while the static type flows through your codebase so your editor and compiler understand the exact shape of your data.

The killer feature is z.infer. Instead of maintaining a TypeScript interface and a separate validation function that can drift apart, you derive the type directly from the schema. Change the schema and the type updates automatically, and every consumer that no longer matches becomes a compile error.

typescript
import { z } from 'zod';

export const signUpSchema = z
  .object({
    email: z.string().email('Enter a valid email address'),
    password: z
      .string()
      .min(8, 'Password must be at least 8 characters')
      .regex(/[A-Z]/, 'Include at least one uppercase letter')
      .regex(/[0-9]/, 'Include at least one number'),
    confirmPassword: z.string(),
    age: z.coerce.number().int().min(18, 'You must be 18 or older'),
    acceptTerms: z.literal(true, {
      errorMap: () => ({ message: 'You must accept the terms' }),
    }),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: 'Passwords do not match',
    path: ['confirmPassword'],
  });

// One line gives you the fully typed shape of valid data.
export type SignUpValues = z.infer<typeof signUpSchema>;

Note z.coerce.number(). HTML inputs always produce strings, so coercion converts the raw string into a number before validation runs. This keeps your form values clean and your SignUpValues type honest: age is typed as number, not string.

How Do You Wire RHF and Zod Together?

The glue is @hookform/resolvers. Install React Hook Form, Zod, and the resolver package, then pass zodResolver(schema) to useForm. Crucially, parameterize useForm with your inferred type so register, handleSubmit, and errors are all aware of the exact fields and their types.

tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { signUpSchema, type SignUpValues } from './schema';

export function SignUpForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<SignUpValues>({
    resolver: zodResolver(signUpSchema),
    defaultValues: {
      email: '',
      password: '',
      confirmPassword: '',
      age: 18,
      acceptTerms: false as unknown as true,
    },
  });

  const onSubmit = async (values: SignUpValues) => {
    // 'values' is fully typed and already validated by Zod.
    await api.signUp(values);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" type="email" {...register('email')} />
        {errors.email && <p role="alert">{errors.email.message}</p>}
      </div>

      <div>
        <label htmlFor="password">Password</label>
        <input id="password" type="password" {...register('password')} />
        {errors.password && <p role="alert">{errors.password.message}</p>}
      </div>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Creating account…' : 'Create account'}
      </button>
    </form>
  );
}

Because useForm is generic over SignUpValues, register('email') autocompletes and rejects register('emial') at compile time. The onSubmit handler receives values already parsed by Zod, so inside it you are working with clean, typed data. handleSubmit will not even call your handler if validation fails.

Type your useForm hook with z.infer of your schema. That single generic parameter is what turns a form from "strings everywhere" into an end-to-end typed pipeline where the compiler is on your side.

Which Validation Mode Should You Use?

By default RHF validates on submit, then re-validates on change once a field has an error. You control this with the mode and reValidateMode options. Choosing the right mode is a UX decision: validate too eagerly and you nag users mid-typing, validate too late and they only discover problems after hitting submit.

The available validation modes and when to reach for each:

  • onSubmit (default): validates only when the form is submitted. Least noisy, best for short forms.
  • onBlur: validates when a field loses focus. A good balance for most forms.
  • onChange: validates on every keystroke. Powerful but can feel aggressive; use sparingly.
  • onTouched: validates after first blur, then on every change. A popular, user-friendly compromise.
  • all: validates on both blur and change. Maximum feedback, maximum re-renders.
tsx
const form = useForm<SignUpValues>({
  resolver: zodResolver(signUpSchema),
  mode: 'onTouched',        // when to run the first validation
  reValidateMode: 'onChange', // how to re-validate after an error appears
});

Controlled inputs with Controller

register works beautifully for native inputs that expose a ref and standard change events. But many UI library components (a custom Select, a date picker, a masked input, a React Native TextInput) do not forward refs or use non-standard value/onChange props. For these, RHF provides the Controller component, which bridges controlled components into the form without giving up type safety.

tsx
import { Controller } from 'react-hook-form';
import { Select } from '@/components/ui/select';

<Controller
  control={control}
  name="country"
  render={({ field, fieldState }) => (
    <div>
      <label htmlFor="country">Country</label>
      <Select
        id="country"
        value={field.value}
        onChange={field.onChange}
        onBlur={field.onBlur}
        aria-invalid={!!fieldState.error}
      />
      {fieldState.error && <p role="alert">{fieldState.error.message}</p>}
    </div>
  )}
/>;

The field object gives you value, onChange, onBlur, name, and ref, all correctly typed against your schema. Use register for native inputs (it is faster and simpler) and reserve Controller for components that need controlled behavior. Mixing both in the same form is completely fine and common.

Nested and array fields with useFieldArray

Real forms rarely have a flat shape. Think of an invoice with multiple line items, a resume with several work experiences, or a survey with a dynamic list of answers. Zod models these naturally with nested objects and arrays, and RHF manages the dynamic list through useFieldArray.

typescript
export const invoiceSchema = z.object({
  customer: z.object({
    name: z.string().min(1, 'Customer name is required'),
    email: z.string().email(),
  }),
  items: z
    .array(
      z.object({
        description: z.string().min(1, 'Description is required'),
        quantity: z.coerce.number().int().positive('Must be at least 1'),
        unitPrice: z.coerce.number().nonnegative(),
      })
    )
    .min(1, 'Add at least one line item'),
});

export type InvoiceValues = z.infer<typeof invoiceSchema>;
tsx
import { useFieldArray, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { invoiceSchema, type InvoiceValues } from './schema';

export function InvoiceForm() {
  const {
    control,
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<InvoiceValues>({
    resolver: zodResolver(invoiceSchema),
    defaultValues: {
      customer: { name: '', email: '' },
      items: [{ description: '', quantity: 1, unitPrice: 0 }],
    },
  });

  const { fields, append, remove } = useFieldArray({
    control,
    name: 'items',
  });

  return (
    <form onSubmit={handleSubmit((v) => console.log(v))} noValidate>
      <input {...register('customer.name')} placeholder="Customer name" />
      {errors.customer?.name && (
        <p role="alert">{errors.customer.name.message}</p>
      )}

      {fields.map((field, index) => (
        <div key={field.id}>
          <input
            {...register(`items.${index}.description`)}
            placeholder="Description"
          />
          <input
            type="number"
            {...register(`items.${index}.quantity`)}
          />
          {errors.items?.[index]?.quantity && (
            <p role="alert">{errors.items[index]?.quantity?.message}</p>
          )}
          <button type="button" onClick={() => remove(index)}>
            Remove
          </button>
        </div>
      ))}

      <button
        type="button"
        onClick={() => append({ description: '', quantity: 1, unitPrice: 0 })}
      >
        Add item
      </button>

      {errors.items?.root && <p role="alert">{errors.items.root.message}</p>}

      <button type="submit">Save invoice</button>
    </form>
  );
}

Two details matter here. First, use field.id (not the array index) as the React key. RHF generates a stable id for each row so reordering and removal do not confuse React reconciliation. Second, nested paths like items.0.quantity and customer.name are fully typed template-literal paths, so typos are caught by TypeScript. Array-level errors (such as the min(1) rule) surface under errors.items.root.

How Do You Handle Cross-Field and Async Validation?

Some rules cannot be expressed on a single field. Passwords must match, an end date must be after a start date, or a chosen discount cannot exceed the subtotal. Zod handles these with refine (for a single check) and superRefine (for multiple checks or fine-grained control over where errors attach).

typescript
const bookingSchema = z
  .object({
    startDate: z.coerce.date(),
    endDate: z.coerce.date(),
    seats: z.coerce.number().int().positive(),
    maxSeats: z.coerce.number().int().positive(),
  })
  .superRefine((data, ctx) => {
    if (data.endDate <= data.startDate) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: 'End date must be after the start date',
        path: ['endDate'],
      });
    }
    if (data.seats > data.maxSeats) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: 'Not enough seats available',
        path: ['seats'],
      });
    }
  });

The path array tells RHF which field the error belongs to, so it appears next to the relevant input instead of at the form level. For asynchronous checks, such as verifying that a username is not already taken, Zod supports async refinements. When your schema contains async logic, use the async parse under the hood; the zodResolver handles this transparently.

typescript
const usernameSchema = z.object({
  username: z
    .string()
    .min(3)
    .refine(
      async (value) => {
        const res = await fetch(`/api/username-available?u=${value}`);
        const { available } = await res.json();
        return available;
      },
      { message: 'This username is already taken' }
    ),
});

Async validation runs on every validation trigger, so debounce it or restrict it to onBlur/onSubmit to avoid hammering your API. A common pattern is to keep cheap synchronous rules on change and run the expensive uniqueness check only on blur or submit.

How Do You Handle Server Errors with setError?

Client validation is a convenience, not a security boundary. Your server will still reject requests: an email already registered, a coupon that just expired, a race condition. When the server responds with field-level errors, map them back onto the form with setError so users see the message right next to the offending input.

tsx
const {
  setError,
  handleSubmit,
  formState: { errors },
} = useForm<SignUpValues>({ resolver: zodResolver(signUpSchema) });

const onSubmit = async (values: SignUpValues) => {
  const res = await fetch('/api/sign-up', {
    method: 'POST',
    body: JSON.stringify(values),
  });

  if (!res.ok) {
    const data = (await res.json()) as {
      fieldErrors?: Record<string, string>;
      formError?: string;
    };

    if (data.fieldErrors) {
      for (const [field, message] of Object.entries(data.fieldErrors)) {
        setError(field as keyof SignUpValues, { type: 'server', message });
      }
    }

    if (data.formError) {
      // Attach a form-wide error not tied to a single field.
      setError('root.serverError', { type: 'server', message: data.formError });
    }
    return;
  }

  // success path
};

Use setError('root.serverError', ...) for errors that are not tied to a specific field, such as "Something went wrong, please try again." Read them from formState.errors.root?.serverError and render a banner at the top of the form.

How Do You Share the Zod Schema Between Client and Server?

This is where the architecture pays off. Because a Zod schema is just a value, you can define it once in a shared module and import it on both sides. The client uses it through zodResolver for instant feedback; the server uses safeParse to guarantee it never trusts client input. No drift, no duplicated rules.

typescript
// lib/schemas/contact.ts (shared)
import { z } from 'zod';

export const contactSchema = z.object({
  name: z.string().min(2, 'Name is too short'),
  email: z.string().email(),
  message: z.string().min(10, 'Tell us a bit more'),
});

export type ContactInput = z.infer<typeof contactSchema>;
typescript
// app/actions/contact.ts (Next.js Server Action)
'use server';

import { contactSchema } from '@/lib/schemas/contact';

export async function submitContact(raw: unknown) {
  const parsed = contactSchema.safeParse(raw);

  if (!parsed.success) {
    // flatten() gives a client-friendly { fieldErrors, formErrors } shape.
    return { ok: false, fieldErrors: parsed.error.flatten().fieldErrors };
  }

  // parsed.data is typed as ContactInput and guaranteed valid.
  await saveMessage(parsed.data);
  return { ok: true };
}

The same pattern works for a plain API route: parse the request body with safeParse, return the flattened errors as JSON on failure, and proceed with parsed.data on success. Your client-side onSubmit then maps those field errors back with setError. One schema, validated at both ends, with a single type describing the payload everywhere it travels.

Building reusable form components

Repeating the label, input, and error markup for every field gets tedious and inconsistent. Following the "don't repeat yourself" principle, extract a typed field component. Using the register return value keeps it lightweight and fully typed.

tsx
import { forwardRef } from 'react';
import type { UseFormRegisterReturn } from 'react-hook-form';

interface TextFieldProps
  extends React.InputHTMLAttributes<HTMLInputElement> {
  label: string;
  error?: string;
  registration: UseFormRegisterReturn;
}

export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
  ({ label, error, registration, id, ...rest }, _ref) => {
    const inputId = id ?? registration.name;
    const errorId = `${inputId}-error`;

    return (
      <div className="field">
        <label htmlFor={inputId}>{label}</label>
        <input
          id={inputId}
          aria-invalid={!!error}
          aria-describedby={error ? errorId : undefined}
          {...registration}
          {...rest}
        />
        {error && (
          <p id={errorId} role="alert" className="field-error">
            {error}
          </p>
        )}
      </div>
    );
  }
);

TextField.displayName = 'TextField';

// Usage keeps forms declarative and consistent:
// <TextField
//   label="Email"
//   registration={register('email')}
//   error={errors.email?.message}
// />

For controlled components, build a similar wrapper around Controller. Centralizing this markup means accessibility attributes, error styling, and layout stay identical across every form in the app, and a fix in one place fixes them everywhere.

Accessibility of error messages

Type safety is worthless if users with assistive technology cannot perceive your validation feedback. A few small habits make forms genuinely accessible.

Accessibility checklist for form errors:

  • Associate every input with a <label htmlFor> pointing at the input id.
  • Set aria-invalid on inputs that currently have an error so screen readers announce the invalid state.
  • Link the error message to the input with aria-describedby pointing at the message element id.
  • Give the error element role="alert" (or aria-live) so it is announced when it appears.
  • Add noValidate to the form so browser native validation bubbles do not compete with your messages.
  • On submit failure, move focus to the first invalid field; RHF does this automatically with shouldFocusError (on by default).

Performance tips

RHF is fast out of the box, but large or complex forms benefit from a few deliberate choices. The goal is always the same: subscribe to the smallest slice of state a component actually needs.

Practical performance guidance:

  • Prefer register (uncontrolled) over Controller when the input supports it; controlled components re-render on every change.
  • Use the useWatch hook instead of watch() to observe specific fields, so only the subscribing component re-renders rather than the whole form.
  • Read isDirty, isValid, and errors through formState carefully; RHF uses a proxy, so only the properties you actually read are subscribed to.
  • For a live-updating summary (like an invoice total), isolate it in its own component with useWatch so the input fields do not re-render alongside it.
  • Set defaultValues up front to avoid uncontrolled-to-controlled warnings and unnecessary resets.
  • Debounce expensive async refinements and prefer onBlur or onSubmit modes for them.
tsx
import { useWatch, type Control } from 'react-hook-form';
import type { InvoiceValues } from './schema';

// Only this component re-renders when items change, not the inputs.
function InvoiceTotal({ control }: { control: Control<InvoiceValues> }) {
  const items = useWatch({ control, name: 'items' });
  const total = items.reduce(
    (sum, item) => sum + item.quantity * item.unitPrice,
    0
  );
  return <p>Total: {total.toFixed(2)}</p>;
}

Key Takeaways

Everything worth remembering from this guide:

  • React Hook Form keeps forms fast by using uncontrolled inputs and minimal re-renders; Zod provides schema-driven validation with inferred types.
  • Define the schema once and derive your form type with z.infer, then pass that type as the generic to useForm for end-to-end type safety.
  • Use register for native inputs and Controller for controlled UI-library components; mix them freely.
  • Model nested objects and dynamic lists with Zod and manage the lists with useFieldArray, keying rows on field.id.
  • Express cross-field rules with refine/superRefine and use the path option to attach errors to the right field; debounce async refinements.
  • Map server-side failures back onto the form with setError, using root.serverError for form-wide messages.
  • Share one Zod schema across client and server, using zodResolver on the client and safeParse on the server so validation never drifts.
  • Extract reusable, accessible field components with proper label, aria-invalid, aria-describedby, and role="alert" wiring.
  • Optimize with useWatch and selective formState reads so only the components that need a value re-render.

Share this article

Related Articles