Forms with React Hook Form + Zod

Reviewed & published by Brayan K

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.

Part of the free React course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn

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:

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

function Signup() {
  const { register, handleSubmit, formState: { errors } } = useForm();

  const onSubmit = (data) => console.log("submit", data);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("email")} placeholder="Email" />
      <input {...register("password")} type="password" />
      <button type="submit">Sign up</button>
    </form>
  );
}

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

// A Zod schema is a set of rules that returns either parsed data or
// a list of issues. We model schema.safeParse(values):

function validateSignup(values) {
  const errors = {};
  if (!values.email.includes("@")) errors.email = "Invalid email";
  if (values.password.length < 8)  errors.password = "Min 8 characters";
  if (values.age < 18)             errors.age = "Must be 18 or older";

  const ok = Object.keys(errors).length === 0;
  return ok
    ? { success: true, data: values }
    : { success: false, errors };
}

console.log(validateSignup({ email: "ada@x.com", password: "longenough", age: 30 }));
// { success: true, data: { ... } }

console.log(validateSignup({ email: "nope", password: "123", age: 12 }));
// { success: false, errors: { email, password, age } }

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:

import { z } from "zod";

const signupSchema = z.object({
  email: z.string().email("Enter a valid email"),
  password: z.string().min(8, "At least 8 characters"),
  age: z.coerce.number().min(18, "You must be 18 or older"),
});

// One source of truth — the type comes from the schema:
type SignupValues = z.infer<typeof signupSchema>;
// { email: string; password: string; age: number }

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

// 🎯 YOUR TURN — fill in the ___ then press "Try it Yourself".
// Add a rule: the username must be at least 3 characters.

function validate(values) {
  const errors = {};
  // 1) If the username is shorter than 3 chars, set an error message.
  if (values.username.length < ___) {     // 👉 use 3
    errors.username = "Username too short";
  }
  return Object.keys(errors).length ? { errors } : { ok: true };
}

console.log(validate({ username: "jo" }));    // { errors: { username: ... } }
console.log(validate({ username: "joanna" })); // { ok: true }

// ✅ Expected output:
//    { errors: { username: 'Username too short' } }
//    { ok: true }

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:

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

function Signup() {
  const { register, handleSubmit, formState: { errors, isSubmitting } } =
    useForm<SignupValues>({ resolver: zodResolver(signupSchema) });

  const onSubmit = (data: SignupValues) => {
    // 'data' is validated AND fully typed here
    return api.signup(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("email")} placeholder="Email" />
      {errors.email && <p className="error">{errors.email.message}</p>}

      <input {...register("password")} type="password" />
      {errors.password && <p className="error">{errors.password.message}</p>}

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

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

// React Hook Form's handleSubmit runs the resolver first. If it passes,
// it calls YOUR onSubmit with clean data; if not, it fills errors instead.

function handleSubmit(validate, onSubmit) {
  return (values) => {
    const result = validate(values);
    if (result.success) {
      onSubmit(result.data);            // ✅ only runs when valid
    } else {
      console.log("errors:", result.errors);   // shown next to fields
    }
  };
}

const validate = (v) =>
  v.email.includes("@")
    ? { success: true, data: v }
    : { success: false, errors: { email: "Invalid email" } };

const submit = handleSubmit(validate, (data) => console.log("POST", data));

submit({ email: "ada@x.com" });   // POST { email: 'ada@x.com' }
submit({ email: "broken" });      // errors: { email: 'Invalid email' }

Reorder Challenge

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

A.   const schema = z.object({ name: z.string().min(1) });
B.     resolver: zodResolver(schema),
C.   const { register, handleSubmit } = useForm({
D.   });
E.   type Values = z.infer<typeof schema>;

Quick Recall

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.

Common Errors (and the fix)

📋 Quick Reference

TaskSyntax
Init formconst { register, handleSubmit } = useForm()
Register field<input {...register("email")} />
Zod schemaz.object({ email: z.string().email() })
Connectresolver: zodResolver(schema)
Infer typez.infer<typeof schema>

Mini-Challenge: Password Match

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

// confirm must equal password (Zod's .refine() does this for real).
function validate(values) {
  // your code here:
  // if values.confirm !== values.password, return { error: "Passwords must match" }
  // otherwise return { ok: true }
}

console.log(validate({ password: "secret12", confirm: "secret12" }));
console.log(validate({ password: "secret12", confirm: "nope" }));

// 🎯 Expected:
//    { ok: true }
//    { error: 'Passwords must match' }

🎉 Lesson Complete

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.

Continue this course

Related lessons