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
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>;
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>
);
}
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
});
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(),
});
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)
),
});
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"],
});
}
});
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.",
});
}
}
};
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>
);
}
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>
);
}
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>
);
}
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");
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:
- Define your schema with Zod — this is your single source of truth for both validation rules and TypeScript types.
-
Infer your type with
z.infer<typeof yourSchema>. -
Pass the schema to
useFormviazodResolver. -
Register your inputs with
{...register("fieldName")}. -
Handle submission with
handleSubmit(onSubmit)— your handler only fires if validation passes. -
Display errors from
formState.errors. -
Inject server errors with
setErrorwhen 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)