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.