---
title: 'React Hook Form + Zod: Type-Safe Forms Tutorial (2026)'
description: 'Build type-safe React forms with React Hook Form 7 and Zod 4: zodResolver setup, register vs Controller, and the z.coerce type-error fix. All runnable.'
url: https://nerdleveltech.com/react-hook-form-zod-resolver-tutorial
language: en
date: '2026-06-26'
category: frontend
tags:
  - react hook form
  - zod
  - react
  - forms
  - validation
  - typescript
  - frontend
markdown_url: https://nerdleveltech.com/react-hook-form-zod-resolver-tutorial.md
attribution: 'Source: NerdLevelTech — https://nerdleveltech.com/react-hook-form-zod-resolver-tutorial'
terms: https://nerdleveltech.com/llms.txt
---

# 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:

```bash
npm create vite@latest my-form-app -- --template react-ts
cd my-form-app
```

Pin the form dependencies so this tutorial stays reproducible:

```bash
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.[^resolvers]

## 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`:

```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()`.[^zod] `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:

```tsx
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.[^resolvers] `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.[^register] Add the JSX inside the component:

```tsx
  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.[^controller] Here it drives a `select` for the role:

```tsx
      <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:

```text
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.[^coerce] 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:

```ts
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.[^coerce]

## 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:

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

Create `test/SignupForm.test.tsx`:

```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.[^coerce]

**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.

## FAQ

**How do I validate a React Hook Form with Zod?**
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`.[^resolvers]

**Does React Hook Form support Zod 4?**
Yes. `@hookform/resolvers` 5.4.0 detects Zod 4 schemas automatically — the `import { zodResolver } from '@hookform/resolvers/zod'` line is unchanged from Zod 3.[^resolvers]

**When should I use `Controller` instead of `register`?**
Use `register` for native `input`, `select`, and `textarea` elements. Use `Controller` for controlled components that expose `value`/`onChange` and don't accept a ref, such as MUI or react-select inputs.[^controller]

**Why does `z.coerce.number()` cause a TypeScript error?**
Coercion makes the schema's input type `unknown` and its output type `number`. Supply both to `useForm` as `useForm<Input, unknown, Output>` so the resolver type-checks.[^coerce]

## 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](https://nerdleveltech.com/tanstack-form-react-zod-validation-tutorial), which uses a controlled, adapter-free model. To take validated data to the server, see [Next.js Server Actions with React 19](https://nerdleveltech.com/nextjs-16-server-actions-react-19-optimistic-ui-tutorial). And if you want to revisit how React state underpins form inputs, [React props and state](https://nerdleveltech.com/react-props-and-state-2025) covers the fundamentals.

[^resolvers]: React Hook Form — Resolvers (Zod and other schema validators). https://github.com/react-hook-form/resolvers
[^zod]: Zod documentation (v4). https://zod.dev
[^register]: React Hook Form — `register` API. https://react-hook-form.com/docs/useform/register
[^controller]: React Hook Form — `Controller` API. https://react-hook-form.com/docs/usecontroller/controller
[^coerce]: React Hook Form resolvers — type mismatch with `z.coerce.number()` (issue #795). https://github.com/react-hook-form/resolvers/issues/795
