Understanding JSON → React Hook Form + Zod
A form, a schema, and one resolver.
Why React Hook Form + Zod has become the modern default, what the resolver does, and how a JSON sample scaffolds a fully-typed form.
Two libraries, one job.
React Hook Form handles uncontrolled input registration, dirty tracking, submission plumbing, and form-level state without re-rendering the whole tree on every keystroke. Zod handles the runtime schema — what the form's value must look like to be considered valid. The zodResolver bridges them: the form's validation function is "parse with this Zod schema; if it throws, the errors map to fields".
One schema, two purposes.
The Zod schema is both the runtime validator and the source of the TypeScript type via z.infer<typeof schema>. The same definition gates the form, types the submit handler, and (if you reuse it server-side) validates the API input. One source of truth across the stack.
A worked form.
From { "email": "a@b.com", "name": "Q", "age": 25 }: import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
const schema = z.object({
email: z.string().email(),
name: z.string().min(1),
age: z.number().int().min(0).max(120),
});
type FormValues = z.infer<typeof schema>;
export function UserForm() {
const { register, handleSubmit, formState: { errors } } =
useForm<FormValues>({ resolver: zodResolver(schema) });
return (
<form onSubmit={handleSubmit(values => console.log(values))}>
<input {...register("email")} />
{errors.email && <span>{errors.email.message}</span>}
...
</form>
);
} The codegen emits the schema, the resolver wiring, and one input per field. Most UIs then style each field; the validation just works.
schema → type → resolver
z.object → z.infer → zodResolver
One definition flows into runtime, type system, and form.
schema = z.object({...}) → FormValues = z.infer<typeof schema>
= Typed form with runtime validation
Why uncontrolled inputs.
React Hook Form registers inputs via {...register("name")} — the input keeps its own state, RHF reads it on submit. The alternative (controlled inputs that re-render the parent on every keystroke) hits performance walls fast on forms of any complexity. Uncontrolled-by-default is the design choice that makes RHF fast; the small ergonomic cost is the trade.
Error messages from Zod.
Zod throws a structured error on validation failure. The resolver translates it into the per-field error map that React Hook Form expects. Default messages are serviceable; z.string().email({ message: "Invalid email address" }) customises them. For multilingual messages, swap in a localised error map at the resolver layer.
Async validation.
For server-side checks (is this email already taken?), the resolver supports async validators via z.string().refine(async (v) => await checkUnique(v)). The form submission blocks until the refine resolves. For frequent checks, debounce the call — every keystroke firing a server request is wasteful.