Command Palette

Search for a command to run...

Hectal
PHASE 5Intermediate ~24 min· topic 7 of 7

Topic 5.7

Form Libraries: React Hook Form and Zod

In one line

React Hook Form manages field registration, submission, errors and touched state for you (mostly with uncontrolled inputs), and Zod describes your data's rules as a schema you can reuse on the client and the server; for small forms plain React is enough.

Think of it like this

Cooking for two versus running a restaurant kitchen. At home you chop, stir and taste by hand, and that's fine. A restaurant serving a hundred plates uses prep stations, standard recipes and checklists, because doing everything by hand no longer scales. Form libraries are the kitchen equipment: worth it when you have many forms with many rules, overkill for one omelette.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Form library
A package that handles a form's values, validation, errors and submission so you write less of it by hand.
React Hook Form
A popular React form library built around the useForm hook and mostly uncontrolled inputs.
register
RHF's function that connects a field: {...register("email")} adds name, onChange, onBlur and ref.
handleSubmit
RHF's wrapper for your submit function: it validates first and calls yours only if the form is valid.
formState.errors
RHF's object of current errors, one entry per invalid field with a message.
Zod
A library that describes data rules as a schema and checks values against it.
Schema
A description of what valid data looks like: which fields, which types, which limits.
Resolver
An adapter that lets RHF use a schema library such as Zod for validation.

Step by step

01Install the three packages

In a real project (not in Hectal's preview, which doesn't include them):

Terminalwhole filebash
$ npm install react-hook-form zod @hookform/resolvers

02Describe the data once with Zod

The schema holds every rule and message. The TypeScript type comes from it for free.

signupSchema.tswhole filetsx
import { z } from "zod";

export const signupSchema = z.object({
  email: z.string().email("Enter an email like name@example.com"),
  password: z.string().min(8, "Use at least 8 characters"),
});

export type SignupValues = z.infer<typeof signupSchema>;
// { email: string; password: string }

03Connect the form with useForm and register

register wires each input; handleSubmit validates before calling your function; errors holds the messages.

SignupForm.tsxwhole filetsx
const { register, handleSubmit, formState: { errors, isSubmitting } } =
  useForm<SignupValues>({ resolver: zodResolver(signupSchema) });

<form onSubmit={handleSubmit(onValid)}>
  <input {...register("email")} aria-invalid={errors.email ? true : undefined} />
  {errors.email && <p>{errors.email.message}</p>}
</form>

04Reuse the schema on the server

The server never trusts the browser: it parses the request body with the same schema.

api/signup.tswhole filetsx
const result = signupSchema.safeParse(await request.json());
if (!result.success) {
  return Response.json({ errors: result.error.flatten().fieldErrors }, { status: 400 });
}
await createUser(result.data); // typed and checked
Reuse the schema on the serverdiagram
Rendering diagram…

05Build a small version yourself

The live examples below build a tiny useForm with register and handleSubmit, and a tiny schema checker with safeParse. Doing it once shows there's no magic: the libraries do what you learned in Topics 5.1–5.6, with more care for edge cases and performance. (Custom hooks are covered properly in Phase 10.)

Try it yourself

  1. 1

    Add a field to the hand-made form

    Add name: "" to defaultValues, a rule ...(!name.trim() && { name: "Enter your name" }) (destructure name in rules), and <input {...register("name")} />. Notice how little each new field costs.

  2. 2

    Add a rule to the schema

    Add a check to password: (v) => (/\d/.test(v) ? null : "Include a number"). Predict which samples change. (Sample 3 still passes: secret123 has digits.)

  3. 3

    Make it validate on blur only

    In the hand-made useForm, show errors for touched fields only, not after submit. Is that a good idea for a field the user never visited? (No: an empty, never-touched required field would never show an error.)

Code & diagrams

React Hook Form + Zod (real project code)tsx

Not live: these packages aren't in Hectal's preview. Zod 4 also offers the shorter `z.email("…")`; `z.string().email()` still works.

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

const signupSchema = z.object({
  email: z.string().email("Enter an email like name@example.com"),
  password: z.string().min(8, "Use at least 8 characters"),
});
type SignupValues = z.infer<typeof signupSchema>;

export function SignupForm() {
  const {
    register,
    handleSubmit,
    reset,
    formState: { errors, isSubmitting },
  } = useForm<SignupValues>({
    resolver: zodResolver(signupSchema),
    defaultValues: { email: "", password: "" },
    mode: "onBlur", // validate when a field loses focus, then on change
  });

  async function onValid(values: SignupValues) {
    await fetch("/api/signup", { method: "POST", body: JSON.stringify(values) });
    reset();
  }

  return (
    <form onSubmit={handleSubmit(onValid)} noValidate>
      <label htmlFor="email">Email</label>
      <input id="email" type="email" {...register("email")}
        aria-invalid={errors.email ? true : undefined} aria-describedby="email-error" />
      {errors.email && <p id="email-error">{errors.email.message}</p>}

      <label htmlFor="password">Password</label>
      <input id="password" type="password" {...register("password")}
        aria-invalid={errors.password ? true : undefined} aria-describedby="password-error" />
      {errors.password && <p id="password-error">{errors.password.message}</p>}

      <button disabled={isSubmitting}>{isSubmitting ? "Creating…" : "Create account"}</button>
    </form>
  );
}
A hand-made useForm with register and handleSubmit New tab

The component reads like the React Hook Form version: `register` spreads the props, `handleSubmit` guards the callback. The first render shows no errors; the empty submit from code shows both. Unlike RHF, this version is controlled, so every keystroke re-renders the form.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

errors shown: {}
errors shown: { email: 'Enter an email like name@example.com', password: 'Use at least 8 characters' }
A hand-made schema with safeParse New tab

Each field has a list of checks; the first failing one gives the message. Zod's real API is richer (types, transforms, nested objects, arrays), but `safeParse` returning success or errors is the same idea.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

1 { success: false, errors: { email: 'Enter your email', password: 'Use at least 8 characters' } }
2 { success: false, errors: { email: 'Enter an email like name@example.com' } }
3 { success: true, data: { email: 'asha@example.com', password: 'secret123' } }

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Forget the spread on register

In the hand-made form, replace {...register("email")} with a plain name="email" on the email input, type a valid email and submit.

terminal
$ Preview
── what you'll see ──
Typing in the email box changes nothing the form knows about: submitting always says "Enter an email like name@example.com".

Break #2

Trust the client schema only

Validate with the schema in the browser and save whatever the API receives.

terminal
$ Server
── what you'll see ──
Records with empty emails and 1-character passwords appear in the database, sent by scripts that never loaded your form.

Myth vs fact

Myth

Every React form needs a form library.

Fact

Most small forms are simpler with useState or FormData and a validate function. Libraries pay off for many, large or complex forms.

Myth

React Hook Form uses controlled inputs like the examples in Topic 5.1.

Fact

It registers fields through refs and reads them from the DOM (uncontrolled), which is why typing doesn't re-render the whole form. Controller is there for components that must be controlled.

Myth

Zod is only for forms.

Fact

Zod validates any data: API responses, environment variables, URL parameters, request bodies. Forms are one use.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    RHF subscribes components to only the parts of formState they read (it uses a Proxy to see which properties you access), so reading formState.errors but not isDirty avoids renders for dirty changes. watch re-renders on every change of the watched fields; useWatch isolates that to a child component.

  • ▸

    Zod schemas can also transform: z.coerce.number() turns the string from a number input into a number (Topic 5.2), and .trim() cleans text. The parsed data is then the clean value you send, not the raw input.

  • ▸

    With React 19 and server frameworks, a common pattern is a form action whose Server Function parses FormData with the Zod schema and returns field errors to useActionState (Phase 19 and Phase 20), sometimes without any client form library at all.

  • ▸

    Choose a library by maintenance and fit: check its release activity and React 19 support. Formik was popular but has seen little development in recent years; React Hook Form and TanStack Form are actively maintained (as of 2026).

Remember this

  1. 1

    Why do form libraries exist? Everything in this phase repeats in every form: state for values, a change handler, touched flags, a submitted flag, a validate function, error display, a sending status, resetting, dynamic rows. Large forms add more: dozens of fields re-rendering on every keystroke, async checks, nested arrays. A library packages those patterns so each form is mostly a description of its fields.

  2. 2

    React Hook Form (RHF) is the most widely used React form library (version 7). Its main hook, useForm(), returns register, handleSubmit and formState. {...register("email", { required: "Enter your email" })} spread onto an input gives it a name, onChange, onBlur and a ref. RHF reads the values through those refs, so fields are uncontrolled (Topic 5.3) and typing doesn't re-render the whole form.

  3. 3

    handleSubmit(onValid) returns a submit handler for <form onSubmit>: it prevents the reload, validates every field, and calls onValid(values) only if all pass; otherwise it fills formState.errors (read errors.email?.message) and focuses the first invalid field. formState.isSubmitting is true while an async onValid runs, reset() puts the defaults back, and useFieldArray handles dynamic rows.

  4. 4

    Zod is a library for describing the shape of data as a schema: z.object({ email: z.string().email("Enter a valid email"), age: z.number().min(18) }). schema.safeParse(data) returns { success: true, data } or { success: false, error } with a message per field. z.infer<typeof schema> turns the schema into a TypeScript type (Phase 12), so the rules and the types can't drift apart.

  5. 5

    The two connect through **zodResolver** from @hookform/resolvers/zod: useForm({ resolver: zodResolver(schema) }). RHF then runs the Zod schema instead of per-field rules. The big win: the same schema can validate the request on the server (an API route or a Server Function, Phase 20), which is the "never trust the client" rule from Topic 5.6 with no duplicated code.

  6. 6

    When plain React is enough: a login, a search, a newsletter box, a settings page with five fields. useState (or FormData) plus a validate function is shorter than learning a library, and React 19 form actions (Phase 19) cover pending and error state. Reach for a library when you have many forms, long forms, field arrays, complex or shared validation, or measured performance problems. Other options exist (TanStack Form, Formik in older code); the ideas are the same.

Explain it without notes

01

Why do form libraries exist? Name the repetitive parts they handle.

02

How do register and handleSubmit work in React Hook Form?

03

What is a schema library like Zod for, and why is sharing a schema with the server valuable?

04

When would you not use a form library?

Practice

01

Extend the hand-made useForm to return a reset() function that puts the defaults back and clears touched and submitted.

02

Write a Zod schema (non-live) for a contact form: name required, email valid, message 10–500 characters, and show how to validate a request body with it.

Trade-offs

  • ↔

    A form library removes boilerplate and improves performance on big forms, but adds a dependency, an API to learn and some indirection when debugging.

  • ↔

    Schema validation keeps rules in one shared place and typed, at the cost of another library and messages that live away from the JSX.

Done when you can

  • I can explain what problems form libraries solve.

  • I can read React Hook Form code with useForm, register, handleSubmit and formState.errors.

  • I can write a Zod schema and use safeParse and zodResolver.

  • I can build a tiny useForm-like hook myself.

  • I can decide when plain React is enough.