Back to the blog
Reactยท Jul 16, 2026ยท 7 min read

React Hook Form + Zod: the form pattern that's become a team standard

Uncontrolled inputs, schema-defined validation, TypeScript types inferred from the schema. Here's the full setup, cross-field validation, async checks, and multi-step forms.

#react#zod#forms#typescript

React Hook Form and Zod have become the settled pair for form handling in TypeScript React apps the way eslint and prettier settled for linting. RHF manages form state with uncontrolled inputs (minimal re-renders), Zod defines the schema (validation rules and TypeScript types in one place), and @hookform/resolvers connects them with a single line of config.

The base setup

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

const schema = z.object({
  email: z.string().email('Enter a valid email'),
  password: z.string().min(8, 'At least 8 characters'),
  confirmPassword: z.string(),
}).refine((d) => d.password === d.confirmPassword, {
  message: 'Passwords do not match',
  path: ['confirmPassword'],
});

type FormValues = z.infer<typeof schema>; // type from schema โ€” no duplication

function SignupForm() {
  const { register, handleSubmit, formState: { errors } } =
    useForm<FormValues>({ resolver: zodResolver(schema) });

  const onSubmit = (data: FormValues) => { /* data is fully typed */ };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('email')} />
      {errors.email && <p>{errors.email.message}</p>}
      <button type="submit">Sign up</button>
    </form>
  );
}

Async validation

For validation requiring a network call โ€” checking if a username is taken, verifying an invitation code โ€” Zod's .refine() accepts an async function. React Hook Form calls the resolver asynchronously on submit. For live validation on blur, use mode: 'onBlur' in useForm options.

const schema = z.object({
  username: z.string().min(3).refine(
    async (val) => {
      const { available } = await fetch(`/api/check-username?u=${val}`).then(r => r.json());
      return available;
    },
    { message: 'Username already taken' }
  ),
});

// mode: 'onBlur' triggers the async check when the user leaves the field
const form = useForm({ resolver: zodResolver(schema), mode: 'onBlur' });

Multi-step forms: one instance, conditional steps

The cleanest multi-step form pattern is a single useForm instance with a step index controlling which fields are rendered. Don't split into separate form instances per step โ€” you lose cross-step validation and have to merge state manually. Validate only the current step's fields before advancing with form.trigger(['fieldA', 'fieldB']).

Patterns that save time

  • Extract schema and defaults into a schema.ts file alongside the form component โ€” reusable in server actions and API route validation.
  • Use z.infer<typeof schema> for the form type; never write a separate TypeScript interface that duplicates the schema.
  • For edit forms, run the API response through z.parse() before passing to defaultValues โ€” normalises the shape and catches drift early.
  • Use formState.isDirty to gate the submit button โ€” prevents unnecessary API calls when nothing changed.

Written by Appesto Engineering.