DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

react-hook-form + zod: I Built a Playground That Shows the Render Count and the Schema-Derived Types

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:

  • onSubmit mode — type into all six fields and the render count barely moves. Validation (and re-render) only happens when you submit.
  • onChange mode — 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
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen 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)