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
- Why controlled inputs get slow, and what RHF does instead
- register fields and wire up handleSubmit
- Declare a Zod schema with rules in one place
- Connect them with zodResolver
- Show per-field messages from formState.errors
- Infer your form's TypeScript type with z.infer
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)
- Forgetting to spread register: write {...register("email")}, not register("email") — it returns props to spread.
- Number fields arrive as strings: an <input> gives text. Use z.coerce.number() so "21" becomes 21 before the min check.
- Wrong resolver import: it's @hookform/resolvers/zod, a separate package.
- Calling onSubmit directly: wrap it — onSubmit={handleSubmit(onSubmit)} — so validation runs first.
📋 Quick Reference
| Task | Syntax |
|---|---|
| Init form | const { register, handleSubmit } = useForm() |
| Register field | <input {...register("email")} /> |
| Zod schema | z.object({ email: z.string().email() }) |
| Connect | resolver: zodResolver(schema) |
| Infer type | z.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
- ✅ RHF keeps inputs uncontrolled — fast forms, less code
- ✅ register wires fields; handleSubmit gates submission
- ✅ A Zod schema declares all rules in one place
- ✅ zodResolver connects schema to form; errors land in formState.errors
- ✅ z.infer gives you the type for free — one source of truth
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
- Previous: React Router: Params, Nested Routes & Loaders
- Next: Global State with Redux Toolkit — Store, slices, and async thunks the modern Redux way
- Quick reference: React cheat sheet