Forms & Validation
Forms are the highest-churn part of most apps. Controlled inputs, schema validation with Zod, async validation, and a single source of truth for errors keep them maintainable. Real forms validate on both the client (UX) and the server (security).
What is this?
A well-built form captures input, validates it against a schema, and submits it to the server. react-hook-form manages state and re-renders efficiently using uncontrolled inputs under the hood, while Zod declares the validation rules as a typed schema shared between client and server. Validation runs at two layers: on the client for instant feedback, and on the server as the only trustworthy gate — client validation is UX, server validation is security.
Why do you need it?
- A typed schema (Zod) is one source of truth: parse once, and the result is guaranteed to match the TypeScript type.
- react-hook-form keeps the number of re-renders low by avoiding one re-render per keystroke per field.
- Server-side re-validation protects against crafted requests that skip the UI entirely.
- Declarative error messages make every field's failure state predictable and testable.
- Async validation (uniqueness of an email, coupon validity) needs careful debounce and race handling — a library helps get it right.
Mental model
A customs checkpoint
Form validation
The officer checks your documents twice — once at the border kiosk for speed (client validation) and again in the inspection hall for truth (server validation). The kiosk makes the line move fast; the hall is what actually protects the country.
How it works
- The schema (z.object) declares each field's type and rules — string, min length, email, refine.
- react-hook-form registers each input with a name; it tracks values without re-rendering the whole form.
- On submit, the resolver parses the raw values with Zod; parse failures map to per-field error messages.
- Valid values are sent to the server, which re-validates with the same schema before touching the database.
- Async checks (email uniqueness) are refined with debounce and race cancellation so slow responses cannot overwrite newer input.
Client and server validation in one flow.
Key commands
npm install react-hook-form zod @hookform/resolversInstall the form, schema and resolver libraries.
added 14 packages ...
npm install @zodios/coreGenerate typed API clients from Zod schemas so client and server always agree.
npm run devStart the dev server to exercise the form.
npx tsx server/validate.tsRun the server-side schema validation script in isolation.
npm testRun unit tests that feed valid and invalid payloads through the schema.
Examples
A form validated with react-hook-form and Zod
import { useForm } from "react-hook-form"import { zodResolver } from "@hookform/resolvers/zod"import { z } from "zod" const userSchema = z.object({ email: z.string().email("Enter a valid email"), age: z.coerce.number().min(18, "Must be 18 or older"),}) export default function SignupForm() { const { register, handleSubmit, formState: { errors } } = useForm({ resolver: zodResolver(userSchema), }) return ( <form onSubmit={handleSubmit(async (values) => { await fetch("/api/signup", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(values), }) })}> <input {...register("email")} type="email" /> {errors.email && <p role="alert">{errors.email.message}</p>} <input {...register("age")} type="number" /> {errors.age && <p role="alert">{errors.age.message}</p>} <button type="submit">Sign up</button> </form> )}zodResolver wires the schema to the form so parse errors become field-level errors. register connects inputs by name. handleSubmit only fires with parsed, type-safe values — and the same userSchema can be imported on the server to re-validate the request body before persistence.
In production
- Checkout flows validate billing fields client-side for speed, then re-validate on the server and surface a 400 summary if the card or address fails.
- Registration forms run async email-uniqueness checks that are debounced and cancelled on every keystroke.
- Zod schemas are shared as a package between frontend and backend, so the API contract and the form rules cannot drift.
- Accessibility-minded teams render every error with role='alert' or aria-describedby so screen readers announce failures.
Best practices
- Keep one Zod schema as the source of truth and share it between client and server.
- Validate on the server unconditionally — the client can never be trusted.
- Show errors on submit or on blur, not on every keystroke, to avoid a wall of red text.
- Coerce HTML string inputs (z.coerce.number) instead of comparing strings to numbers.
- Debounce async validators and abort/cancel stale requests so slow responses can't overwrite newer input.
Common mistakes
- Trusting client-side validation alone and skipping server re-validation — crafted requests bypass the UI.
- Comparing form values as strings ('18' < 9 is true) instead of coercing to numbers first.
- Using one giant error state object instead of letting the resolver map errors to fields.
- Rendering every error at once on every keystroke, which reads as hostile UX.
Common issues
ZodError: expected number, received stringHTML inputs always submit strings; the schema declared a number without coercion.
Use z.coerce.number() (and coerce for booleans/dates) so the schema converts before validating.
All fields error out on submit even when the form looks validThe resolver and the schema libraries mismatch (zodResolver given a yup schema).
Ensure the resolver matches the schema library: @hookform/resolvers/zod with a Zod schema.
Server returns 400 for data the client validated as validClient and server run different schemas or rules.
Share the same schema module between the frontend and backend packages.
Debugging tips
- Log the parsed result: if parse fails, Zod's issues array has path, message and expected vs received.
- Test the schema directly in a script (npx tsx) with representative good/bad inputs before wiring the UI.
- Inspect the Network tab on submit — a 400 means the server disagreed with the client about validity.
- Check which resolver you passed: a mismatch (e.g., yup resolver with a zod schema) silently produces no errors.
Interview checkpoint
Hands-on lab
- Build a form with react-hook-form and a Zod schema
- Show per-field errors on submit
- Re-validate the same schema on the server
- Test valid and invalid payloads against the API
Validate a Signup Form End to End
45 min · intermediate
Quick quiz
Which library's job is declaring validation rules as a typed schema?
Client-side validation alone is enough to protect your API from invalid data.
An HTML number input submits '18' as a string. Which schema change validates it as a number?
Cheat sheet
z.string().email()Valid email formatz.coerce.number().min(18)Coerce string → number, then range checkz.string().min(8).max(64)Length boundsz.object({ ... }).refine(fn)Cross-field or async rulesz.enum(['a','b'])Allowlist of valuesschema.parse vs safeParseThrows vs returns a result objectregister('name')Connect an input to the formzodResolver(schema)Use a Zod schema as the resolverformState.errorsPer-field error messageshandleSubmit(onValid)Runs only when parsing passes