react-hook-form + zod is the de-facto React form stack — but two of its best ideas are invisible: how little it re-renders, and how one schema is the single source of truth for validation and types. So I built a playground that makes both visible.
▶ Live demo: https://form-lab.vercel.app/
Source (React 19 + RHF + zod): https://github.com/dev48v/form-lab
Type into a real form and watch the zod errors, the form-state flags, and — the headline — a live render counter.
The re-render thing nobody shows you
Controlled forms (useState per input) re-render the component on every keystroke. react-hook-form keeps inputs uncontrolled and subscribes to changes via refs, so it barely re-renders. The lab proves it with a counter:
-
onSubmitmode — type into all six fields and the render count barely moves. Validation (and re-render) only happens when you submit. -
onChangemode — the count climbs with every key, because now it validates live.
Same form, one config change, wildly different render pressure. That's the perf win people cite but rarely see.
One schema → validation and types
The zod schema is the whole contract:
const schema = z.object({
email: z.string().min(1, "Required").email(),
username: z.string().min(3).max(20),
age: z.coerce.number().int().min(18).max(120),
website: z.union([z.literal(""), z.string().url()]),
password: z.string().min(8).regex(/[A-Z]/).regex(/[0-9]/),
confirm: z.string(),
}).refine(d => d.password === d.confirm, {
message: "Passwords don't match", path: ["confirm"],
});
type FormData = z.infer<typeof schema>; // types DERIVED from the schema
Wire it up with the resolver and the form is fully typed and validated from that one object:
const { register, handleSubmit, formState } =
useForm<FormData>({ resolver: zodResolver(schema), mode });
Change a rule and the type changes with it — they can't drift apart.
The cross-field gotcha
"Passwords don't match" isn't a single-field rule. It's a schema-level .refine() that targets path: ["confirm"] — so the error lands on the confirm field even though the check needs both. Watching where that error appears clears up one of the most common first-zod-schema confusions.
Also in the lab: isValid / isDirty / touchedFields / submitCount live, age coerced from string to number by zod, and an optional URL via z.union([z.literal(""), z.string().url()]).
If it demystified the RHF + zod stack for you, a star helps others find it: https://github.com/dev48v/form-lab
Top comments (0)