React Hook Form Validation Tutorial: From Basics to Zod Schemas

Learn React Hook Form validation step by step—from register and Controller to type‑safe Zod schemas, async checks, and real‑world tips.

ASOasis
7 min read
React Hook Form Validation Tutorial: From Basics to Zod Schemas

Image used for representation purposes only.

Overview

React Hook Form (RHF) is a lightweight, performant library for building forms in React using hooks. It favors uncontrolled inputs and native browser behavior, which keeps renders minimal and makes complex forms snappy. In this step‑by‑step tutorial, you’ll learn how to validate forms with built‑in rules, integrate controlled UI components, add schema validation (with Zod), and handle real‑world needs like async and cross‑field checks. As of March 2026, the latest stable RHF release is in the 7.71.x series (v7.71.2, released February 20, 2026). (github.com )

What you’ll build

  • A basic form using register and HTML validation rules
  • A version that uses controlled UI components via Controller
  • A type‑safe schema‑validated form using Zod and @hookform/resolvers
  • Cross‑field and async validations, plus tips for accessibility and UX

Prerequisites

  • React 18+ and basic hooks knowledge
  • Node.js and a React app scaffolded with your preferred tool (Vite, Next.js, Create React App)
  • TypeScript is optional but recommended for schema‑driven validation

Install the dependencies

Run the following in your project directory:

# Core
npm i react-hook-form

# Optional: schema validation with Zod
npm i zod @hookform/resolvers

The resolvers package provides plug‑and‑play adapters for validation libraries (Zod, Yup, Ajv, and others). Recent versions can infer input/output types from your schema and require react-hook-form v7.55.0 or higher. (github.com )

1) RHF in 5 minutes: built‑in validation rules

Register inputs with RHF and pass standard constraints (required, minLength, pattern, etc.). RHF will collect values, run validation, and expose errors.

import { useForm } from 'react-hook-form';

type FormValues = {
  firstName: string;
  email: string;
  age?: number;
};

export default function QuickStartForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<FormValues>({
    mode: 'onSubmit',          // onBlur | onChange | onTouched | all
    reValidateMode: 'onChange' // how to re-validate after user edits
  });

  const onSubmit = (data: FormValues) => {
    console.log('Valid data:', data);
  };

  return (
    <form noValidate onSubmit={handleSubmit(onSubmit)}>
      <label>
        First name
        <input
          {...register('firstName', { required: 'First name is required' })}
          aria-invalid={!!errors.firstName}
        />
      </label>
      {errors.firstName && <p role="alert">{errors.firstName.message}</p>}

      <label>
        Email
        <input
          type="email"
          {...register('email', {
            required: 'Email is required',
            pattern: {
              value: /[^\s@]+@[^\s@]+\.[^\s@]+/,
              message: 'Please enter a valid email',
            },
          })}
          aria-invalid={!!errors.email}
        />
      </label>
      {errors.email && <p role="alert">{errors.email.message}</p>}

      <label>
        Age (optional)
        <input type="number" {...register('age', { valueAsNumber: true, min: { value: 13, message: '13+' } })} />
      </label>

      <button disabled={isSubmitting}>Submit</button>
    </form>
  );
}

The quickstart API—register, handleSubmit, and formState—is the foundation of RHF. (github.com )

Useful options you’ll use often

  • mode and reValidateMode control when validation runs (change, blur, submit).
  • criteriaMode: ‘all’ collects all rule failures for a field.
  • shouldFocusError: automatically focuses the first invalid field on submit.

2) Controlled components with Controller

Many UI libraries (Material UI, Radix-based inputs, custom masks) expose controlled inputs that don’t forward refs or native events the way RHF expects. Use Controller to bridge those components into RHF’s ecosystem.

import { useForm, Controller } from 'react-hook-form';
import { TextField, Checkbox } from '@mui/material';

type Profile = {
  username: string;
  terms: boolean;
};

export default function ControlledForm() {
  const { control, handleSubmit } = useForm<Profile>({
    defaultValues: { username: '', terms: false },
  });

  return (
    <form onSubmit={handleSubmit(console.log)}>
      <Controller
        control={control}
        name="username"
        rules={{ required: 'Username is required' }}
        render={({ field, fieldState }) => (
          <TextField
            {...field}
            label="Username"
            error={!!fieldState.error}
            helperText={fieldState.error?.message}
          />
        )}
      />

      <Controller
        control={control}
        name="terms"
        rules={{ validate: v => v || 'You must accept the terms' }}
        render={({ field, fieldState }) => (
          <label style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
            <Checkbox checked={field.value} onChange={(_, v) => field.onChange(v)} />
            <span>Accept terms</span>
            {fieldState.error && <span role="alert">{fieldState.error.message}</span>}
          </label>
        )}
      />

      <button>Save</button>
    </form>
  );
}

3) Add schema validation with Zod (type‑safe)

Schema validation centralizes your rules, gives precise error messages, and unlocks end‑to‑end type inference. Zod 4 is the current major version and works great with RHF via the zodResolver. (zod.dev )

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

const SignupSchema = z
  .object({
    name: z.string().min(1, 'Name is required'),
    email: z.string().email('Invalid email'),
    password: z.string().min(8, 'Use at least 8 characters'),
    confirmPassword: z.string(),
  })
  .refine((data) => data.password === data.confirmPassword, {
    path: ['confirmPassword'],
    message: 'Passwords must match',
  });

// Let types be inferred from your schema
type Signup = z.infer<typeof SignupSchema>;

export default function ZodSignupForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isValid },
  } = useForm<Signup>({
    resolver: zodResolver(SignupSchema),
    mode: 'onChange',
  });

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

      <input placeholder="Email" type="email" {...register('email')} />
      {errors.email && <p role="alert">{errors.email.message}</p>}

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

      <input placeholder="Confirm password" type="password" {...register('confirmPassword')} />
      {errors.confirmPassword && <p role="alert">{errors.confirmPassword.message}</p>}

      <button disabled={!isValid}>Create account</button>
    </form>
  );
}

Tip: @hookform/resolvers v5 introduced improved type inference and expects react-hook-form v7.55.0+. If you see type mismatches, upgrade both packages together. (github.com )

4) Cross‑field, async, and conditional validation

You can combine RHF’s field‑level rules, schema refinements, and programmatic APIs:

  • Cross‑field validation: Use a Zod refine (as above) or RHF’s getValues/watch inside a custom validate rule.
// Example: end date must be after start date
const EventSchema = z.object({
  start: z.date(),
  end: z.date(),
}).refine(({ start, end }) => end > start, {
  path: ['end'],
  message: 'End must be after start',
});
  • Async uniqueness checks: Validate with an async function in rules or call trigger on blur.
<Controller
  name="username"
  control={control}
  rules={{
    validate: async (value) => {
      const res = await fetch(`/api/username-exists?u=${encodeURIComponent(value)}`);
      const { exists } = await res.json();
      return exists ? 'That username is taken' : true;
    },
  }}
  render={({ field }) => <input {...field} />}
/>
  • Conditional fields: watch a value and conditionally render other fields; RHF will automatically register/unregister them as they mount/unmount.

5) Dynamic fields with useFieldArray

When the UI lets users add/remove rows (addresses, phones, line items), useFieldArray for performant operations and predictable field names.

import { useForm, useFieldArray } from 'react-hook-form';

type Invoice = {
  customer: string;
  items: { description: string; qty: number; price: number }[];
};

function InvoiceForm() {
  const { control, register, handleSubmit } = useForm<Invoice>({
    defaultValues: { customer: '', items: [{ description: '', qty: 1, price: 0 }] },
  });

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

  return (
    <form onSubmit={handleSubmit(console.log)}>
      <input placeholder="Customer" {...register('customer', { required: 'Required' })} />

      {fields.map((f, i) => (
        <div key={f.id}>
          <input placeholder="Description" {...register(`items.${i}.description` as const, { required: 'Required' })} />
          <input type="number" {...register(`items.${i}.qty` as const, { valueAsNumber: true, min: 1 })} />
          <input type="number" step="0.01" {...register(`items.${i}.price` as const, { valueAsNumber: true, min: 0 })} />
          <button type="button" onClick={() => remove(i)}>Remove</button>
        </div>
      ))}

      <button type="button" onClick={() => append({ description: '', qty: 1, price: 0 })}>Add item</button>
      <button>Submit</button>
    </form>
  );
}

6) Error messages, accessibility, and UX

  • Use aria-invalid and role=“alert” so screen readers announce issues.
  • Focus the first invalid field by setting shouldFocusError: true on useForm.
  • Prefer clear, specific messages (“Use at least 8 characters”) to generic ones.
  • Disable the submit button when isSubmitting to prevent double posts.

7) Editing flows and resetting values

When loading existing data, set defaultValues initially and call reset(fetchedData) after your API returns. This ensures the form mirrors the server state without extra renders.

const form = useForm<User>({ defaultValues: { name: '', email: '' } });

useEffect(() => {
  (async () => {
    const data = await fetch('/api/me').then(r => r.json());
    form.reset(data);
  })();
}, []);

8) Testing validation

  • Keep logic in schemas and pure functions so you can unit‑test them directly.
  • For integration tests, use @testing-library/react to simulate user flows (fill inputs, blur, click submit) and assert on error messages.

9) Troubleshooting checklist

  • Versions in sync: Upgrade react-hook-form, @hookform/resolvers, and your schema library together—resolvers v5 requires RHF ≥ 7.55.0 and adds improved type inference. (github.com )
  • Controlled inputs: Wrap them with Controller; ensure you pass value and onChange correctly from field.
  • Numbers/dates: Use valueAsNumber or valueAsDate in register to parse native input values.
  • All errors for a field: Set criteriaMode: ‘all’ and aggregate messages in your UI.

10) Why Zod with RHF?

Zod offers a concise, TypeScript‑first API with excellent ecosystem support and a stable v4 release. Combined with RHF and the zodResolver, you get ergonomic runtime validation plus compile‑time safety, with minimal code. (zod.dev )

Reference and further reading

  • React Hook Form repository and quickstart examples (register, handleSubmit, formState). (github.com )
  • Zod documentation and v4 announcement. (zod.dev )
  • @hookform/resolvers usage, supported schema libraries, and TypeScript inference. (github.com )
  • Latest RHF version as of March 2026. (github.com )

Wrap‑up

You now have a complete toolkit for form validation in React: start with RHF’s built‑in rules for speed, reach for Controller when a UI component is controlled, and graduate to Zod schemas for reliable, type‑safe validation. With these patterns, your forms will be fast, accessible, and maintainable—even as they grow in complexity.

Related Posts