frontend

React Hook Form + Zod: Type-Safe Forms Tutorial (2026)

June 26, 2026

React Hook Form + Zod: Type-Safe Forms Tutorial (2026)

To validate a React form with Zod, install react-hook-form and @hookform/resolvers, pass zodResolver(schema) to useForm, and read field errors from formState.errors. Zod infers your form's types, so one schema drives both validation and TypeScript safety.

TL;DR

You will build a fully type-safe signup form with React Hook Form 7.80.0 and Zod 4.4.3, wired together by @hookform/resolvers 5.4.0. We cover the zodResolver setup, register for native inputs, Controller for controlled components, and the TypeScript error many developers hit the first time they use z.coerce.number() — plus the input/output-generics fix. Every snippet here type-checks under TypeScript 6 and passes a real Testing Library suite. Budget about 20 minutes.

What you'll learn

  • How to install and pin React Hook Form, the Zod resolver, and Zod
  • How to define one Zod schema that validates and types your form
  • How to connect the schema with zodResolver and render field errors
  • When to use register versus Controller
  • How to fix the z.coerce "type is not assignable" resolver error
  • How to handle submit state and write a test that proves it works
  • How to troubleshoot five common React Hook Form + Zod errors

Prerequisites

  • Node.js 20.19+ or 22.12+ (required by Vite 8)
  • Familiarity with React function components and hooks
  • A new React + TypeScript app. If you do not have one:
npm create vite@latest my-form-app -- --template react-ts
cd my-form-app

Pin the form dependencies so this tutorial stays reproducible:

npm install react-hook-form@7.80.0 @hookform/resolvers@5.4.0 zod@4.4.3

@hookform/resolvers is the small bridge package that lets React Hook Form delegate validation to a schema library. Its Zod entry point auto-detects whether you are on Zod 3 or Zod 4, so the same zodResolver import works with Zod 4.4.3.1

Step 1: Define the Zod schema

A single schema is the source of truth for both runtime validation and your TypeScript types. Create src/schema.ts:

import { z } from 'zod';

export const signupSchema = z
  .object({
    name: z.string().min(2, 'Name must be at least 2 characters'),
    email: z.email('Enter a valid email address'),
    age: z.coerce.number().int().min(18, 'You must be at least 18'),
    password: z.string().min(8, 'Password must be at least 8 characters'),
    confirmPassword: z.string(),
    role: z.enum(['developer', 'designer', 'manager'], {
      error: 'Select a role',
    }),
    terms: z.literal(true, { error: 'You must accept the terms' }),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: 'Passwords do not match',
    path: ['confirmPassword'],
  });

Three Zod 4 details matter here. z.email() is now a top-level validator rather than z.string().email().2 z.coerce.number() turns the string an <input> always produces ("32") into a real number, and z.literal(true) is how you force a "must be checked" terms box. The .refine() at the end is a cross-field rule: it compares password and confirmPassword and attaches any error to the confirmPassword path.

One subtlety to remember: a cross-field .refine() on the object only runs after every field-level check passes. If the password is too short, you will see the "at least 8 characters" error first, and the "Passwords do not match" message only appears once both fields are otherwise valid.

Step 2: Wire up useForm with zodResolver

Now connect the schema to React Hook Form. Create src/SignupForm.tsx and start with the hook:

import { useForm, Controller, type SubmitHandler } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { signupSchema } from './schema';

type SignupInput = z.input<typeof signupSchema>;
type SignupOutput = z.output<typeof signupSchema>;

export function SignupForm({ onValid }: { onValid: (data: SignupOutput) => void }) {
  const {
    register,
    handleSubmit,
    control,
    formState: { errors, isSubmitting },
  } = useForm<SignupInput, unknown, SignupOutput>({
    resolver: zodResolver(signupSchema),
    mode: 'onTouched',
    defaultValues: {
      name: '',
      email: '',
      password: '',
      confirmPassword: '',
    },
  });

  const onSubmit: SubmitHandler<SignupOutput> = (data) => {
    onValid(data);
  };

  // ...form JSX in the next step
}

zodResolver(signupSchema) is the entire integration — React Hook Form will call Zod on every validation event and convert Zod issues into its errors object.1 mode: 'onTouched' validates a field after its first blur and then live on every change, which is the friendliest default for signup forms. The three generics on useForm (<SignupInput, unknown, SignupOutput>) are not optional decoration here — they are what makes z.coerce type-check. We explain exactly why in Step 5.

Step 3: Register native inputs and render errors

register is React Hook Form's core API for native inputs. It returns the name, onChange, onBlur, and ref props and spreads them onto an element, keeping the input uncontrolled — React Hook Form reads the value from the DOM via the ref instead of storing it in React state. That uncontrolled model is what lets the library minimize re-renders by default.3 Add the JSX inside the component:

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      <input aria-label="name" {...register('name')} />
      {errors.name && <p role="alert">{errors.name.message}</p>}

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

      <input aria-label="age" type="number" {...register('age')} />
      {errors.age && <p role="alert">{errors.age.message}</p>}

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

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

      {/* role + terms + submit go here in the next steps */}
    </form>
  );

handleSubmit wraps your onSubmit so it only fires when validation passes. Each error lives at errors.<field>.message, already populated with the exact string from your Zod schema. The role="alert" attribute means screen readers announce validation failures as they appear.

Step 4: Controller for controlled components

register works for native input, select, and textarea elements. But many real forms use components that don't accept a ref and instead expose their own value/onChange — think MUI, react-select, or a custom design-system field. For those, wrap the field in Controller, which subscribes the component to the form without a ref.4 Here it drives a select for the role:

      <Controller
        control={control}
        name="role"
        render={({ field }) => (
          <select aria-label="role" {...field} value={field.value ?? ''}>
            <option value="">Select…</option>
            <option value="developer">Developer</option>
            <option value="designer">Designer</option>
            <option value="manager">Manager</option>
          </select>
        )}
      />
      {errors.role && <p role="alert">{errors.role.message}</p>}

      <input aria-label="terms" type="checkbox" {...register('terms')} />
      {errors.terms && <p role="alert">{errors.terms.message}</p>}

      <button type="submit" disabled={isSubmitting}>
        Create account
      </button>

The rule of thumb: reach for register first because it is cheaper, and only use Controller when a component cannot take a ref. The terms checkbox is a native input, so it stays on register; the role select is wrapped to show the Controller pattern you will reuse for third-party inputs.

Step 5: Fix the z.coerce type error

If you typed useForm<SignupOutput> with a single generic, TypeScript would reject your resolver with a long, confusing error:

Type 'Resolver<{ ...; age: unknown; ... }, any, { ...; age: number; ... }>'
is not assignable to type 'Resolver<{ ...; age: number; ... }>'.
  Type 'unknown' is not assignable to type 'number'.

This is one of the most common React Hook Form + Zod TypeScript errors, and it is logged across the resolver and Zod trackers.5 The cause is precise: z.coerce.number() accepts an unknown input (it will coerce whatever the input is) but produces a number output. So your schema has two different types — the values the form holds before validation, and the values you get after. A single generic forces them to be the same type, and they aren't.

The fix is to give React Hook Form both types explicitly, which is exactly what we did in Step 2:

type SignupInput = z.input<typeof signupSchema>;   // age: unknown
type SignupOutput = z.output<typeof signupSchema>; // age: number

useForm<SignupInput, unknown, SignupOutput>({
  resolver: zodResolver(signupSchema),
});

The first generic is the field-values type, the third is the validated (transformed) type your submit handler receives. With both supplied, z.coerce type-checks cleanly and onSubmit is correctly typed: data.age is a number, not a string. If you would rather not touch the generics, two alternatives produce the same runtime result: replace z.coerce.number() with z.transform(Number).pipe(z.number()), or drop coercion from the schema and register the field with register('age', { valueAsNumber: true }) so React Hook Form converts it for you.5

Verification: prove it works with a test

Verification should be runnable, not "looks right." Install the test tooling and add a Vitest + Testing Library suite that submits the form for real:

npm install -D vitest @testing-library/react @testing-library/user-event \
  @testing-library/jest-dom jsdom

Create test/SignupForm.test.tsx:

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { z } from 'zod';
import { SignupForm } from '../src/SignupForm';
import { signupSchema } from '../src/schema';

type SignupValues = z.output<typeof signupSchema>;

it('shows validation errors on empty submit', async () => {
  const user = userEvent.setup();
  render(<SignupForm onValid={() => {}} />);
  await user.click(screen.getByRole('button', { name: /create account/i }));
  expect(
    await screen.findByText('Name must be at least 2 characters'),
  ).toBeInTheDocument();
  expect(screen.getByText('Enter a valid email address')).toBeInTheDocument();
  expect(screen.getByText('You must accept the terms')).toBeInTheDocument();
});

it('submits parsed values with age coerced to a number', async () => {
  const user = userEvent.setup();
  let received: SignupValues | undefined;
  render(<SignupForm onValid={(d) => { received = d; }} />);
  await user.type(screen.getByLabelText('name'), 'Ada Lovelace');
  await user.type(screen.getByLabelText('email'), 'ada@example.com');
  await user.type(screen.getByLabelText('age'), '32');
  await user.type(screen.getByLabelText('password'), 'sup3rsecret');
  await user.type(screen.getByLabelText('confirmPassword'), 'sup3rsecret');
  await user.selectOptions(screen.getByLabelText('role'), 'developer');
  await user.click(screen.getByLabelText('terms'));
  await user.click(screen.getByRole('button', { name: /create account/i }));
  expect(received).toBeDefined();
  expect(typeof received!.age).toBe('number');
  expect(received!.age).toBe(32);
});

Run it with npx vitest run (add a jsdom environment in vitest.config.ts). Both tests pass: the empty submit surfaces the Zod messages, and the valid submit hands your onSubmit a payload where age is the number 32, not the string "32" — proof that coercion and the input/output generics are working end to end.

Troubleshooting

Type 'unknown' is not assignable to type 'number' on the resolver. You used z.coerce (or a transform) with a single useForm generic. Add the input and output types: useForm<z.input<typeof schema>, unknown, z.output<typeof schema>>. See Step 5.5

Errors never show up. You probably forgot to read from formState.errors, or your input isn't registered. Confirm each field is spread with {...register('fieldName')} and that the name matches a key in the schema exactly.

The "passwords do not match" message won't appear. A cross-field .refine() only runs after every field passes its own checks. Fill both passwords with valid 8+ character strings that differ, and the refine error will fire.

age arrives as a string in onSubmit. Without coercion, an <input type="number"> still yields a string. Use z.coerce.number() in the schema (as shown) or register('age', { valueAsNumber: true }).

A controlled component throws "changing an uncontrolled input to controlled." Give the field a defaultValues entry, or fall back to value={field.value ?? ''} inside Controller as we do for the role select.

Next steps and further reading

You now have a type-safe form where one Zod schema drives validation, error messages, and the onSubmit payload type. From here, explore field arrays for dynamic lists, async validation for uniqueness checks, and server integration.

If you're weighing libraries, compare this approach with TanStack Form and Zod, which uses a controlled, adapter-free model. To take validated data to the server, see Next.js Server Actions with React 19. And if you want to revisit how React state underpins form inputs, React props and state covers the fundamentals.

Footnotes

  1. React Hook Form — Resolvers (Zod and other schema validators). https://github.com/react-hook-form/resolvers 2 3 4

  2. Zod documentation (v4). https://zod.dev

  3. React Hook Form — register API. https://react-hook-form.com/docs/useform/register

  4. React Hook Form — Controller API. https://react-hook-form.com/docs/usecontroller/controller 2

  5. React Hook Form resolvers — type mismatch with z.coerce.number() (issue #795). https://github.com/react-hook-form/resolvers/issues/795 2 3 4

Frequently Asked Questions

Install @hookform/resolvers , then pass resolver: zodResolver(yourSchema) to useForm . React Hook Form runs Zod on each validation event and maps issues to formState.errors . 1