React cookbook · Intermediate

Validate forms with React Hook Form and Zod

Build a signup form with field-level errors, server error mapping and accessible messages.

Approach

Define a Zod schema (shared with the server if you use TypeScript there), connect it to React Hook Form via zodResolver, show errors linked with aria-describedby, and map server-side errors (e.g., "email taken") back to fields with setError.

Solution

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 a valid email"),
  password: z.string().min(12, "Use at least 12 characters"),
  confirm: z.string(),
}).refine((v) => v.password === v.confirm, { path: ["confirm"], message: "Passwords don't match" });

type SignupValues = z.infer<typeof signupSchema>;

export function SignupForm() {
  const { register, handleSubmit, setError, formState: { errors, isSubmitting } } =
    useForm<SignupValues>({ resolver: zodResolver(signupSchema) });

  async function onSubmit(values: SignupValues) {
    const res = await fetch("/api/signup", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(values) });
    if (res.status === 409) return setError("email", { message: "An account with this email already exists" });
    if (!res.ok) return setError("root", { message: "Something went wrong. Try again." });
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      <label htmlFor="email">Email</label>
      <input id="email" type="email" autoComplete="email" aria-invalid={!!errors.email} aria-describedby="email-err" {...register("email")} />
      {errors.email && <p id="email-err" role="alert">{errors.email.message}</p>}

      <label htmlFor="password">Password</label>
      <input id="password" type="password" autoComplete="new-password" aria-invalid={!!errors.password} aria-describedby="pw-err" {...register("password")} />
      {errors.password && <p id="pw-err" role="alert">{errors.password.message}</p>}

      <label htmlFor="confirm">Confirm password</label>
      <input id="confirm" type="password" autoComplete="new-password" aria-invalid={!!errors.confirm} aria-describedby="confirm-err" {...register("confirm")} />
      {errors.confirm && <p id="confirm-err" role="alert">{errors.confirm.message}</p>}

      {errors.root && <p role="alert">{errors.root.message}</p>}
      <button disabled={isSubmitting}>{isSubmitting ? "Creating account…" : "Create account"}</button>
    </form>
  );
}

Pitfalls

  • Relying on client validation only — the server must validate too.
  • Errors shown only with color.
  • Missing autoComplete attributes (password managers).

Variations

  • React 19 useActionState with server-side validation for simpler forms.
  • Validate on blur (mode: 'onBlur') for less noisy UX.

Further reading: React Hook Form: useForm

Reference: https://react-hook-form.com/docs/useform

More React recipes