← back to section

A form is one of the most routine interface elements. A few fields and a button — or so it seems. Behind them, though, lies a real task: collect the input, validate it, show errors — and send it to the server in exactly the format it expects.

What's wrong with the naive approach

The first thing that comes to mind is storing each field in useState and checking everything manually:

const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [emailError, setEmailError] = useState("");

function validate() {
  if (!email.includes("@")) setEmailError("Invalid email");
  // and so on for each field...
}

With 3–4 fields this is still manageable. With 10–15 fields it turns into a sprawl: as many state variables as there are fields, plus as many for errors, plus validation logic scattered across the file. Any change to a field re-renders the entire component — even if only one of fifteen fields changed.

The react-hook-form + zod combination approaches this differently: field management and validation rules live separately from useState, and each layer does its own job.

react-hook-form: fields without unnecessary re-renders

react-hook-form registers fields via register and treats them as uncontrolled — meaning it doesn't store each field's value in React state. The DOM holds the current value itself; the library reads it on submit. This means typing in one field doesn't re-render the others.

import { useForm } from "react-hook-form";

type LoginForm = { email: string; password: string };

function Login() {
  const { register, handleSubmit, formState: { errors } } = useForm<LoginForm>();

  const onSubmit = (data: LoginForm) => {
    // data is typed — email and password are already strings
    console.log(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("email")} />
      {errors.email && <span>{errors.email.message}</span>}

      <input type="password" {...register("password")} />
      {errors.password && <span>{errors.password.message}</span>}

      <button type="submit">Sign in</button>
    </form>
  );
}

handleSubmit intercepts the form submission, collects all field values, and calls your callback — but only if the form passed validation. Field errors are available via formState.errors.

zod: validation rules in one place

The useState approach hides validation rules inside functions — and they easily drift away from what the server actually accepts. zod lets you describe the shape of the data declaratively, as a schema:

import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";

const loginSchema = z.object({
  email: z.string().email("Enter a valid email"),
  password: z.string().min(8, "Minimum 8 characters"),
});

type LoginForm = z.infer<typeof loginSchema>; // the type is inferred from the schema automatically

function Login() {
  const { register, handleSubmit, formState: { errors } } =
    useForm<LoginForm>({ resolver: zodResolver(loginSchema) });

  // ...
}

z.infer<typeof schema> derives the TypeScript type directly from the schema — the type and the rules can't diverge because they are the same thing. zodResolver connects the schema to the form: on submit, react-hook-form runs the values through the schema and populates formState.errors with the messages from the schema.

A slightly more complex example — a product creation form:

const createProductSchema = z.object({
  name: z.string().min(1, "Enter a name").max(200),
  price: z.number().int().positive("Price must be greater than zero"),
  category: z.enum(["electronics", "clothing", "food"]),
});

type CreateProductForm = z.infer<typeof createProductSchema>;

Server errors

Client-side validation catches obvious errors before submission — typos, empty required fields, wrong format. But the server can return errors that the client can't check in advance: "email already taken," "this SKU already exists," "insufficient permissions."

react-hook-form handles this via setError — you can set an error programmatically and it appears next to the relevant field:

const { register, handleSubmit, formState: { errors }, setError } = useForm<LoginForm>({
  resolver: zodResolver(loginSchema),
});

const onSubmit = async (data: LoginForm) => {
  try {
    await login(data);
  } catch (err) {
    if (err.code === "EMAIL_NOT_FOUND") {
      setError("email", { message: "No user found with this email" });
    }
  }
};

This is an important architectural detail: client-side validation is for fast feedback, server-side validation is the source of truth. Both are needed: the client reacts instantly, the server guarantees correctness.

Numbers and type coercion

One non-obvious thing: HTML fields always return strings. If a field is <input type="number" />, the value in data will still arrive as a string — and zod will throw a type error if the schema expects z.number().

The fix is z.coerce.number(): zod converts the string to a number before validation:

const schema = z.object({
  price: z.coerce.number().positive("Price must be greater than zero"),
  quantity: z.coerce.number().int().min(1),
});

Default values

For edit forms — when you need to load existing data and let the user change it — useForm accepts defaultValues:

const { register, handleSubmit } = useForm<ProductForm>({
  resolver: zodResolver(productSchema),
  defaultValues: {
    name: product.name,
    price: product.price,
  },
});

If the data loads asynchronously, you can pass defaultValues later via reset:

useEffect(() => {
  if (product) reset(product);
}, [product]);

In short

  • The naive approach (field → useState, errors → separate useState) grows proportionally with the number of fields.
  • react-hook-form works with uncontrolled fields — typing in one field doesn't re-render the others.
  • zod describes validation rules declaratively; z.infer derives the TypeScript type from the same schema.
  • zodResolver connects the schema to the form; schema errors flow into formState.errors.
  • Server errors are set via setError — they appear next to the relevant field.
  • z.coerce.number() converts the string from <input> to a number before validation.
  • For edit forms — defaultValues on creation or reset after data loads.
  • Client-side validation is fast feedback; server-side is the source of truth. Both are needed.
  • Data fetching: TanStack Query — how to send form data as a mutation and handle the server response.
  • Project structure — where to keep zod schemas and how they connect to request types.
  • Routing: React Router — where to navigate after a successful form submission.