Forms with React Hook Form + Zod

Real forms are tedious: tracking values, validating each field, showing errors, blocking invalid submits. React Hook Form handles the wiring with fast, uncontrolled inputs, and Zod lets you declare your validation rules once as a schema — which also gives you the TypeScript types for free. Together they're the modern standard for forms that are short to write, fast to run, and type-safe end to end.

Learn Forms with React Hook Form + Zod in our free React course — a beginner-friendly interactive lesson with runnable examples, a practice exercise and a…

Part of the free React course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

1️⃣ The Performance Problem

With controlled inputs, every keystroke calls setState and re-renders the whole form. RHF keeps inputs uncontrolled (via refs) so typing in one field doesn't re-render the rest:

Validation is just a set of rules that turns values into pass-or-issues. The demo models exactly that schema behavior:

2️⃣ Declaring a Zod Schema

Write your rules once. Each chained method adds a constraint and an optional message. The same schema validates at runtime and produces your TypeScript type:

Your turn — add the missing constraint so a short username is rejected:

3️⃣ Wiring Them Together

The zodResolver connects the schema to the form. On submit, RHF runs the values through Zod; valid data reaches your onSubmit , and any failures appear in errors keyed by field:

The demo models that submit gate: onSubmit only runs when validation passes, otherwise errors are produced instead:

These lines wiring RHF to Zod are scrambled. Put them in the correct order:

1) Why doesn't typing in one RHF field re-render the others?

RHF uses uncontrolled inputs (refs) instead of state per keystroke, so the component doesn't re-render as you type.

2) Where do validation messages show up after a failed submit?

In formState.errors , keyed by field name, e.g. errors.email.message .

3) How do you get the form's TypeScript type from the schema?

type Values = z.infer<typeof schema> — one source of truth for rules and types.

📋 Quick Reference

A classic cross-field rule: confirm must equal password . (In Zod you'd use .refine() .) Model the check so mismatches produce an error.

Practice quiz

Why are React Hook Form inputs faster than controlled inputs?

  • They skip validation
  • They use Web Workers
  • They're uncontrolled (registered via refs), so typing in one field doesn't re-render the others
  • They debounce every keystroke

Answer: They're uncontrolled (registered via refs), so typing in one field doesn't re-render the others. Controlled inputs re-render the whole form on each keystroke; RHF uses uncontrolled refs to avoid that.

What does Zod provide?

  • Schema validation plus TypeScript types inferred from the same schema
  • A CSS framework
  • A routing library
  • A state store

Answer: Schema validation plus TypeScript types inferred from the same schema. Zod is a TypeScript-first schema library that validates at runtime and infers types from one schema.

How do you register a field with RHF?

  • register('email') passed directly
  • useField('email')
  • <input ref='email' />
  • Spread it: <input {...register('email')} />

Answer: Spread it: <input {...register('email')} />. register returns props you spread onto the input with {...register('email')}.

What does zodResolver do?

  • Fetches data
  • Adapts a Zod schema so React Hook Form validates against it
  • Generates components
  • Resolves promises

Answer: Adapts a Zod schema so React Hook Form validates against it. zodResolver plugs a Zod schema into useForm via resolver: zodResolver(schema).

Where does zodResolver come from?

  • @hookform/resolvers/zod
  • react-hook-form
  • zod
  • react-dom

Answer: @hookform/resolvers/zod. It lives in the separate @hookform/resolvers/zod package.

After a failed submit, where do validation messages appear?

  • In the console only
  • In a thrown exception
  • In formState.errors, keyed by field name (e.g. errors.email.message)
  • In useState

Answer: In formState.errors, keyed by field name (e.g. errors.email.message). RHF surfaces messages in formState.errors keyed by field name.

How do you get the form's TypeScript type from a Zod schema?

  • typeof schema
  • z.infer<typeof schema>
  • schema.type()
  • Write a separate interface

Answer: z.infer<typeof schema>. z.infer<typeof schema> derives the type from the schema — one source of truth.

Why use z.coerce.number() for an input bound to a number rule?

  • It rounds the value
  • It makes the field required
  • It speeds up validation
  • An <input> gives text, so coercion turns "21" into 21 before the min check

Answer: An <input> gives text, so coercion turns "21" into 21 before the min check. Inputs return strings; z.coerce.number() converts before numeric validation runs.

How should you wire onSubmit so validation runs first?

  • onSubmit={onSubmit}
  • onSubmit={handleSubmit(onSubmit)}
  • onSubmit={onSubmit()}
  • onSubmit={register(onSubmit)}

Answer: onSubmit={handleSubmit(onSubmit)}. handleSubmit(onSubmit) runs the resolver first and only calls your onSubmit with valid data.

For a cross-field rule like 'confirm must equal password' in Zod, you'd use…

  • .min()
  • .email()
  • .refine()
  • .coerce()

Answer: .refine(). .refine() lets you express custom/cross-field validation in a Zod schema.