Topic 5.7
Form Libraries: React Hook Form and Zod
In one line
React Hook Form manages field registration, submission, errors and touched state for you (mostly with uncontrolled inputs), and Zod describes your data's rules as a schema you can reuse on the client and the server; for small forms plain React is enough.
Think of it like this
Cooking for two versus running a restaurant kitchen. At home you chop, stir and taste by hand, and that's fine. A restaurant serving a hundred plates uses prep stations, standard recipes and checklists, because doing everything by hand no longer scales. Form libraries are the kitchen equipment: worth it when you have many forms with many rules, overkill for one omelette.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Form library
- A package that handles a form's values, validation, errors and submission so you write less of it by hand.
- React Hook Form
- A popular React form library built around the
useFormhook and mostly uncontrolled inputs. - register
- RHF's function that connects a field:
{...register("email")}adds name, onChange, onBlur and ref. - handleSubmit
- RHF's wrapper for your submit function: it validates first and calls yours only if the form is valid.
- formState.errors
- RHF's object of current errors, one entry per invalid field with a
message. - Zod
- A library that describes data rules as a schema and checks values against it.
- Schema
- A description of what valid data looks like: which fields, which types, which limits.
- Resolver
- An adapter that lets RHF use a schema library such as Zod for validation.
Step by step
01Install the three packages
In a real project (not in Hectal's preview, which doesn't include them):
$ npm install react-hook-form zod @hookform/resolvers02Describe the data once with Zod
The schema holds every rule and message. The TypeScript type comes from it for free.
import { z } from "zod";
export const signupSchema = z.object({
email: z.string().email("Enter an email like name@example.com"),
password: z.string().min(8, "Use at least 8 characters"),
});
export type SignupValues = z.infer<typeof signupSchema>;
// { email: string; password: string }03Connect the form with useForm and register
register wires each input; handleSubmit validates before calling your function; errors holds the messages.
const { register, handleSubmit, formState: { errors, isSubmitting } } =
useForm<SignupValues>({ resolver: zodResolver(signupSchema) });
<form onSubmit={handleSubmit(onValid)}>
<input {...register("email")} aria-invalid={errors.email ? true : undefined} />
{errors.email && <p>{errors.email.message}</p>}
</form>04Reuse the schema on the server
The server never trusts the browser: it parses the request body with the same schema.
const result = signupSchema.safeParse(await request.json());
if (!result.success) {
return Response.json({ errors: result.error.flatten().fieldErrors }, { status: 400 });
}
await createUser(result.data); // typed and checked05Build a small version yourself
The live examples below build a tiny useForm with register and handleSubmit, and a tiny schema checker with safeParse. Doing it once shows there's no magic: the libraries do what you learned in Topics 5.1–5.6, with more care for edge cases and performance. (Custom hooks are covered properly in Phase 10.)
Try it yourself
- 1
Add a field to the hand-made form
Add
name: ""todefaultValues, a rule...(!name.trim() && { name: "Enter your name" })(destructurenameinrules), and<input {...register("name")} />. Notice how little each new field costs. - 2
Add a rule to the schema
Add a check to
password:(v) => (/\d/.test(v) ? null : "Include a number"). Predict which samples change. (Sample 3 still passes:secret123has digits.) - 3
Make it validate on blur only
In the hand-made
useForm, show errors for touched fields only, not after submit. Is that a good idea for a field the user never visited? (No: an empty, never-touched required field would never show an error.)
Code & diagrams
Not live: these packages aren't in Hectal's preview. Zod 4 also offers the shorter `z.email("…")`; `z.string().email()` still works.
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const signupSchema = z.object({
email: z.string().email("Enter an email like name@example.com"),
password: z.string().min(8, "Use at least 8 characters"),
});
type SignupValues = z.infer<typeof signupSchema>;
export function SignupForm() {
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<SignupValues>({
resolver: zodResolver(signupSchema),
defaultValues: { email: "", password: "" },
mode: "onBlur", // validate when a field loses focus, then on change
});
async function onValid(values: SignupValues) {
await fetch("/api/signup", { method: "POST", body: JSON.stringify(values) });
reset();
}
return (
<form onSubmit={handleSubmit(onValid)} noValidate>
<label htmlFor="email">Email</label>
<input id="email" type="email" {...register("email")}
aria-invalid={errors.email ? true : undefined} aria-describedby="email-error" />
{errors.email && <p id="email-error">{errors.email.message}</p>}
<label htmlFor="password">Password</label>
<input id="password" type="password" {...register("password")}
aria-invalid={errors.password ? true : undefined} aria-describedby="password-error" />
{errors.password && <p id="password-error">{errors.password.message}</p>}
<button disabled={isSubmitting}>{isSubmitting ? "Creating…" : "Create account"}</button>
</form>
);
}The component reads like the React Hook Form version: `register` spreads the props, `handleSubmit` guards the callback. The first render shows no errors; the empty submit from code shows both. Unlike RHF, this version is controlled, so every keystroke re-renders the form.
Preview
Console
Nothing logged yet.Expected console output
errors shown: {}
errors shown: { email: 'Enter an email like name@example.com', password: 'Use at least 8 characters' }Each field has a list of checks; the first failing one gives the message. Zod's real API is richer (types, transforms, nested objects, arrays), but `safeParse` returning success or errors is the same idea.
Preview
Console
Nothing logged yet.Expected console output
1 { success: false, errors: { email: 'Enter your email', password: 'Use at least 8 characters' } }
2 { success: false, errors: { email: 'Enter an email like name@example.com' } }
3 { success: true, data: { email: 'asha@example.com', password: 'secret123' } }Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Forget the spread on register
In the hand-made form, replace {...register("email")} with a plain name="email" on the email input, type a valid email and submit.
Break #2
Trust the client schema only
Validate with the schema in the browser and save whatever the API receives.
Myth vs fact
Myth
Every React form needs a form library.
Fact
Most small forms are simpler with useState or FormData and a validate function. Libraries pay off for many, large or complex forms.
Myth
React Hook Form uses controlled inputs like the examples in Topic 5.1.
Fact
It registers fields through refs and reads them from the DOM (uncontrolled), which is why typing doesn't re-render the whole form. Controller is there for components that must be controlled.
Myth
Zod is only for forms.
Fact
Zod validates any data: API responses, environment variables, URL parameters, request bodies. Forms are one use.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
RHF subscribes components to only the parts of
formStatethey read (it uses a Proxy to see which properties you access), so readingformState.errorsbut notisDirtyavoids renders for dirty changes.watchre-renders on every change of the watched fields;useWatchisolates that to a child component. - ▸
Zod schemas can also transform:
z.coerce.number()turns the string from a number input into a number (Topic 5.2), and.trim()cleans text. The parseddatais then the clean value you send, not the raw input. - ▸
With React 19 and server frameworks, a common pattern is a form action whose Server Function parses
FormDatawith the Zod schema and returns field errors touseActionState(Phase 19 and Phase 20), sometimes without any client form library at all. - ▸
Choose a library by maintenance and fit: check its release activity and React 19 support. Formik was popular but has seen little development in recent years; React Hook Form and TanStack Form are actively maintained (as of 2026).
Remember this
- 1
Why do form libraries exist? Everything in this phase repeats in every form: state for values, a change handler, touched flags, a submitted flag, a validate function, error display, a sending status, resetting, dynamic rows. Large forms add more: dozens of fields re-rendering on every keystroke, async checks, nested arrays. A library packages those patterns so each form is mostly a description of its fields.
- 2
React Hook Form (RHF) is the most widely used React form library (version 7). Its main hook,
useForm(), returnsregister,handleSubmitandformState.{...register("email", { required: "Enter your email" })}spread onto an input gives it aname,onChange,onBlurand aref. RHF reads the values through those refs, so fields are uncontrolled (Topic 5.3) and typing doesn't re-render the whole form. - 3
handleSubmit(onValid)returns a submit handler for<form onSubmit>: it prevents the reload, validates every field, and callsonValid(values)only if all pass; otherwise it fillsformState.errors(readerrors.email?.message) and focuses the first invalid field.formState.isSubmittingistruewhile anasynconValidruns,reset()puts the defaults back, anduseFieldArrayhandles dynamic rows. - 4
Zod is a library for describing the shape of data as a schema:
z.object({ email: z.string().email("Enter a valid email"), age: z.number().min(18) }).schema.safeParse(data)returns{ success: true, data }or{ success: false, error }with a message per field.z.infer<typeof schema>turns the schema into a TypeScript type (Phase 12), so the rules and the types can't drift apart. - 5
The two connect through **
zodResolver** from@hookform/resolvers/zod:useForm({ resolver: zodResolver(schema) }). RHF then runs the Zod schema instead of per-field rules. The big win: the same schema can validate the request on the server (an API route or a Server Function, Phase 20), which is the "never trust the client" rule from Topic 5.6 with no duplicated code. - 6
When plain React is enough: a login, a search, a newsletter box, a settings page with five fields.
useState(or FormData) plus a validate function is shorter than learning a library, and React 19 form actions (Phase 19) cover pending and error state. Reach for a library when you have many forms, long forms, field arrays, complex or shared validation, or measured performance problems. Other options exist (TanStack Form, Formik in older code); the ideas are the same.
Explain it without notes
Why do form libraries exist? Name the repetitive parts they handle.
How do register and handleSubmit work in React Hook Form?
What is a schema library like Zod for, and why is sharing a schema with the server valuable?
When would you not use a form library?
Practice
Extend the hand-made useForm to return a reset() function that puts the defaults back and clears touched and submitted.
Write a Zod schema (non-live) for a contact form: name required, email valid, message 10–500 characters, and show how to validate a request body with it.
Trade-offs
- ↔
A form library removes boilerplate and improves performance on big forms, but adds a dependency, an API to learn and some indirection when debugging.
- ↔
Schema validation keeps rules in one shared place and typed, at the cost of another library and messages that live away from the JSX.
Done when you can
I can explain what problems form libraries solve.
I can read React Hook Form code with
useForm,register,handleSubmitandformState.errors.I can write a Zod schema and use
safeParseandzodResolver.I can build a tiny
useForm-like hook myself.I can decide when plain React is enough.