DEV Community

Nuwan Madusanka
Nuwan Madusanka

Posted on

React Hook forms + Zod

Stop Writing Messy Form Validation — Use React Hook Form + Zod Instead

A practical guide to building bulletproof, type-safe forms in React without the headache.


Forms are the backbone of almost every web application. Yet, they're often where codebases go to die — a graveyard of useState chains, scattered if statements, and validation logic copy-pasted across components.

There's a better way. React Hook Form paired with Zod gives you clean, performant, fully type-safe forms with minimal boilerplate. This article will walk you through everything you need to know, from setup to real-world patterns.


Why This Stack?

Before diving in, let's understand why this combination has become the de-facto standard in modern React development.

React Hook Form (RHF) manages your form state without unnecessary re-renders. Unlike Formik, it doesn't wrap your entire form in a context that re-renders on every keystroke. It uses uncontrolled inputs and refs under the hood, giving you excellent performance out of the box.

Zod is a TypeScript-first schema declaration and validation library. You define the shape of your data once, and Zod handles both runtime validation AND TypeScript type inference. No more duplicating your types and your validators — they live in a single source of truth.

Together, they eliminate an entire category of bugs.


Installation

npm install react-hook-form zod @hookform/resolvers
Enter fullscreen mode Exit fullscreen mode

The @hookform/resolvers package is the bridge between RHF and Zod (it also supports Yup, Joi, and others if you ever need to switch).


Your First Form

Let's build a user registration form. We'll start with the Zod schema.

import { z } from "zod";

const registrationSchema = z
  .object({
    username: z
      .string()
      .min(3, "Username must be at least 3 characters")
      .max(20, "Username cannot exceed 20 characters")
      .regex(/^[a-zA-Z0-9_]+$/, "Only letters, numbers, and underscores allowed"),
    email: z.string().email("Please enter a valid email address"),
    password: z
      .string()
      .min(8, "Password must be at least 8 characters")
      .regex(/[A-Z]/, "Must contain at least one uppercase letter")
      .regex(/[0-9]/, "Must contain at least one number"),
    confirmPassword: z.string(),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: "Passwords do not match",
    path: ["confirmPassword"],
  });

// Infer the TypeScript type directly from the schema
type RegistrationFormData = z.infer<typeof registrationSchema>;
Enter fullscreen mode Exit fullscreen mode

Notice what just happened. We defined our validation rules AND derived our TypeScript type from the same schema. The z.infer<> utility means your type is always in sync with your validation — zero maintenance overhead.

Now the form component:

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

export function RegistrationForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<RegistrationFormData>({
    resolver: zodResolver(registrationSchema),
    defaultValues: {
      username: "",
      email: "",
      password: "",
      confirmPassword: "",
    },
  });

  const onSubmit = async (data: RegistrationFormData) => {
    // `data` is fully typed and validated — safe to use
    await registerUser(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label>Username</label>
        <input {...register("username")} placeholder="johndoe_99" />
        {errors.username && <p className="error">{errors.username.message}</p>}
      </div>

      <div>
        <label>Email</label>
        <input {...register("email")} type="email" placeholder="john@example.com" />
        {errors.email && <p className="error">{errors.email.message}</p>}
      </div>

      <div>
        <label>Password</label>
        <input {...register("password")} type="password" />
        {errors.password && <p className="error">{errors.password.message}</p>}
      </div>

      <div>
        <label>Confirm Password</label>
        <input {...register("confirmPassword")} type="password" />
        {errors.confirmPassword && (
          <p className="error">{errors.confirmPassword.message}</p>
        )}
      </div>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "Creating account..." : "Register"}
      </button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

That's it. No useState for each field. No manual onChange handlers. No onBlur juggling. React Hook Form registers each input, Zod validates the entire form on submit, and errors surface automatically.


Understanding the Key APIs

register connects an input to RHF's internal state. The spread {...register("fieldName")} passes name, ref, onChange, and onBlur props to the input element.

handleSubmit wraps your submit handler. It first runs Zod validation, and only calls your onSubmit function if the data is valid. If invalid, it populates formState.errors.

formState.errors is a deeply nested object that mirrors the shape of your schema. Each field's error contains a message string from your Zod definitions.

zodResolver is the adapter that takes your Zod schema and translates Zod's validation output into RHF's error format.


Controlling Validation Trigger Behavior

By default, validation fires on submit. You can change this with the mode option:

const form = useForm({
  resolver: zodResolver(schema),
  mode: "onChange",       // validate as user types
  // mode: "onBlur",      // validate when field loses focus
  // mode: "onTouched",   // validate on first blur, then on change
  // mode: "all",         // validate on both change and blur
});
Enter fullscreen mode Exit fullscreen mode

For a great user experience, "onTouched" is often the sweet spot — it won't yell at users before they've interacted with a field, but gives immediate feedback once they have.


Advanced Zod Patterns

Optional Fields with Defaults

const profileSchema = z.object({
  displayName: z.string().min(1, "Required"),
  bio: z.string().max(160).optional(),
  website: z.string().url("Must be a valid URL").optional().or(z.literal("")),
  age: z.number().min(13).max(120).optional(),
});
Enter fullscreen mode Exit fullscreen mode

The .or(z.literal("")) pattern is useful for URL fields — it allows the field to be empty without triggering the URL validator.

Transforming Input Data

Zod can transform data during validation, which is powerful for normalizing user input:

const schema = z.object({
  email: z.string().email().transform((val) => val.toLowerCase().trim()),
  tags: z
    .string()
    .transform((val) =>
      val
        .split(",")
        .map((t) => t.trim())
        .filter(Boolean)
    ),
});
Enter fullscreen mode Exit fullscreen mode

Conditional Validation with superRefine

const checkoutSchema = z
  .object({
    paymentMethod: z.enum(["card", "bank_transfer"]),
    cardNumber: z.string().optional(),
    bankAccount: z.string().optional(),
  })
  .superRefine((data, ctx) => {
    if (data.paymentMethod === "card" && !data.cardNumber) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "Card number is required",
        path: ["cardNumber"],
      });
    }
    if (data.paymentMethod === "bank_transfer" && !data.bankAccount) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "Bank account is required",
        path: ["bankAccount"],
      });
    }
  });
Enter fullscreen mode Exit fullscreen mode

superRefine gives you full control over the validation context, letting you add multiple issues with specific paths.


Handling Server-Side Errors

Validation shouldn't only happen on the client. When your API returns errors, you can inject them back into the form using setError:

const { setError } = useForm({ resolver: zodResolver(schema) });

const onSubmit = async (data: FormData) => {
  try {
    await api.register(data);
  } catch (error) {
    if (error.code === "USERNAME_TAKEN") {
      setError("username", {
        type: "server",
        message: "This username is already taken",
      });
    } else if (error.code === "EMAIL_EXISTS") {
      setError("email", {
        type: "server",
        message: "An account with this email already exists",
      });
    } else {
      setError("root", {
        type: "server",
        message: "Something went wrong. Please try again.",
      });
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

The "root" error key is useful for form-level errors that don't belong to a specific field.


Building Reusable Field Components

For larger applications, you'll want to extract field logic into reusable components. Use useFormContext to access the form state without prop drilling:

// FormField.tsx
import { useFormContext } from "react-hook-form";

interface FormFieldProps {
  name: string;
  label: string;
  type?: string;
  placeholder?: string;
}

export function FormField({ name, label, type = "text", placeholder }: FormFieldProps) {
  const {
    register,
    formState: { errors },
  } = useFormContext();

  const error = errors[name];

  return (
    <div className={`field ${error ? "field--error" : ""}`}>
      <label htmlFor={name}>{label}</label>
      <input
        id={name}
        type={type}
        placeholder={placeholder}
        {...register(name)}
        aria-invalid={!!error}
        aria-describedby={error ? `${name}-error` : undefined}
      />
      {error && (
        <p id={`${name}-error`} className="field__error" role="alert">
          {error.message as string}
        </p>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Wrap your form with FormProvider to make the context available:

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

export function RegistrationForm() {
  const methods = useForm<RegistrationFormData>({
    resolver: zodResolver(registrationSchema),
  });

  return (
    <FormProvider {...methods}>
      <form onSubmit={methods.handleSubmit(onSubmit)}>
        <FormField name="username" label="Username" placeholder="johndoe_99" />
        <FormField name="email" label="Email" type="email" />
        <FormField name="password" label="Password" type="password" />
        <FormField name="confirmPassword" label="Confirm Password" type="password" />
        <button type="submit">Register</button>
      </form>
    </FormProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Dynamic Fields with useFieldArray

RHF has first-class support for dynamic lists of fields:

const experienceSchema = z.object({
  experiences: z.array(
    z.object({
      company: z.string().min(1, "Company name is required"),
      role: z.string().min(1, "Role is required"),
      years: z.number().min(0).max(50),
    })
  ).min(1, "Add at least one experience"),
});

function ExperienceForm() {
  const { control, register, handleSubmit, formState: { errors } } = useForm({
    resolver: zodResolver(experienceSchema),
    defaultValues: { experiences: [{ company: "", role: "", years: 0 }] },
  });

  const { fields, append, remove } = useFieldArray({
    control,
    name: "experiences",
  });

  return (
    <form onSubmit={handleSubmit(console.log)}>
      {fields.map((field, index) => (
        <div key={field.id}>
          <input {...register(`experiences.${index}.company`)} placeholder="Company" />
          <input {...register(`experiences.${index}.role`)} placeholder="Role" />
          <input {...register(`experiences.${index}.years`, { valueAsNumber: true })} type="number" />
          <button type="button" onClick={() => remove(index)}>Remove</button>
        </div>
      ))}
      <button type="button" onClick={() => append({ company: "", role: "", years: 0 })}>
        Add Experience
      </button>
      <button type="submit">Save</button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Note the valueAsNumber: true option in register — this tells RHF to coerce the string input value to a number before validation, which is essential for Zod's z.number() to work correctly on native <input type="number"> elements.


Performance Tips

Watch specific fields, not everything. Use watch sparingly — watching the entire form causes re-renders on every keystroke. Instead, be specific:

// ❌ Expensive — re-renders on any field change
const allValues = watch();

// ✅ Only re-renders when "paymentMethod" changes
const paymentMethod = watch("paymentMethod");
Enter fullscreen mode Exit fullscreen mode

Use getValues for non-reactive reads. If you need the current value of a field inside an event handler without triggering re-renders, use getValues() instead of watch().

Avoid controlled inputs unless necessary. The beauty of RHF is uncontrolled inputs. Only reach for Controller (for custom components like date pickers or select libraries) when you actually need it.


The Complete Picture

Here's a summary of the pattern:

  1. Define your schema with Zod — this is your single source of truth for both validation rules and TypeScript types.
  2. Infer your type with z.infer<typeof yourSchema>.
  3. Pass the schema to useForm via zodResolver.
  4. Register your inputs with {...register("fieldName")}.
  5. Handle submission with handleSubmit(onSubmit) — your handler only fires if validation passes.
  6. Display errors from formState.errors.
  7. Inject server errors with setError when your API responds.

Closing Thoughts

React Hook Form and Zod is one of those pairings that genuinely makes you enjoy writing forms again. The performance is excellent, the developer experience is top-tier, and the TypeScript integration means your form data is safe from end-to-end.

The next time you find yourself reaching for a pile of useState hooks and custom validation functions, remember: there's a better way, and it's only three packages away.


If you found this useful, consider following for more React and TypeScript deep dives. Got questions or edge cases I missed? Drop them in the comments.


Tags: React · TypeScript · Web Development · JavaScript · Frontend

Top comments (0)