Full Stack
Intermediate 13 min read Full Stack

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).

controlled inputsreact-hook-formzodschema validationasync validationerror messagesmultipart
Introduction

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.

Motivation

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.
Think about it

Mental model

Imagine

A customs checkpoint

Think of

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.

Mechanics

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.

User types
Client validate
Show field errors
Submit JSON
Server re-validate
Persist or 400
Reference

Key commands

npm install react-hook-form zod @hookform/resolvers

Install the form, schema and resolver libraries.

added 14 packages ...
npm install @zodios/core

Generate typed API clients from Zod schemas so client and server always agree.

npm run dev

Start the dev server to exercise the form.

npx tsx server/validate.ts

Run the server-side schema validation script in isolation.

npm test

Run unit tests that feed valid and invalid payloads through the schema.

Hands-on

Examples

A form validated with react-hook-form and Zod

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.

Real world

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.
Guidelines

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.
Watch out

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.
Root cause analysis

Common issues

Error
ZodError: expected number, received string
Root cause

HTML inputs always submit strings; the schema declared a number without coercion.

Fix

Use z.coerce.number() (and coerce for booleans/dates) so the schema converts before validating.

Error
All fields error out on submit even when the form looks valid
Root cause

The resolver and the schema libraries mismatch (zodResolver given a yup schema).

Fix

Ensure the resolver matches the schema library: @hookform/resolvers/zod with a Zod schema.

Error
Server returns 400 for data the client validated as valid
Root cause

Client and server run different schemas or rules.

Fix

Share the same schema module between the frontend and backend packages.

When things break

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.
Interviews

Interview checkpoint

Practice

Hands-on lab

Validate a Signup Form End to End
45 min · intermediate
In progress
Objectives
  • 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

0/5 steps
0%
sandbox · Linux sandbox
$
Try these commands
Test yourself

Quick quiz

0/3
1

Which library's job is declaring validation rules as a typed schema?

2

Client-side validation alone is enough to protect your API from invalid data.

3

An HTML number input submits '18' as a string. Which schema change validates it as a number?

Quick reference

Cheat sheet

Zod schema building blocks
z.string().email()Valid email format
z.coerce.number().min(18)Coerce string → number, then range check
z.string().min(8).max(64)Length bounds
z.object({ ... }).refine(fn)Cross-field or async rules
z.enum(['a','b'])Allowlist of values
schema.parse vs safeParseThrows vs returns a result object
react-hook-form
register('name')Connect an input to the form
zodResolver(schema)Use a Zod schema as the resolver
formState.errorsPer-field error messages
handleSubmit(onValid)Runs only when parsing passes
Go deeper

Official documentation