Jul 14, 2026 · 12 min read ·
Why React Hook Form and Zod Work Better Together (Part 2)
This continues directly from Part 1, where we built a checkout form up through React Hook Form with some basic cross-field validation, and started seeing the cracks: watch() bringing re-renders back, and validation logic split awkwardly across JSX conditionals and validate functions.
Adding more fields
Two months later, your PM says you need more fields for a smoother checkout: country, postal code (format depends on country), payment method (card/PayPal/bank), card expiry + CVV (required only for card), a coupon code (must match a valid list), and a terms checkbox.
Show code — Version 4: React Hook Form with more fields
import { useForm } from "react-hook-form";
const VALID_COUPONS = ["SAVE10", "FREESHIP", "WELCOME20"];
function CheckoutForm() {
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm({ mode: "onBlur" });
const email = watch("email");
const billingSameAsShipping = watch("billingSameAsShipping");
const country = watch("country");
const paymentMethod = watch("paymentMethod");
const onSubmit = (data) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
className={errors.email ? "input-error" : ""}
{...register("email", {
required: "Email is required",
pattern: { value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: "Enter a valid email address" },
})}
/>
{errors.email && <span className="error">{errors.email.message}</span>}
</div>
<div>
<label htmlFor="confirmEmail">Confirm Email</label>
<input
id="confirmEmail"
type="email"
className={errors.confirmEmail ? "input-error" : ""}
{...register("confirmEmail", {
required: "Please confirm your email",
validate: (value) => value === email || "Emails do not match",
})}
/>
{errors.confirmEmail && <span className="error">{errors.confirmEmail.message}</span>}
</div>
<div>
<label htmlFor="shippingAddress">Shipping Address</label>
<input
id="shippingAddress"
type="text"
className={errors.shippingAddress ? "input-error" : ""}
{...register("shippingAddress", {
required: "Shipping address is required",
minLength: { value: 5, message: "Address seems too short" },
})}
/>
{errors.shippingAddress && <span className="error">{errors.shippingAddress.message}</span>}
</div>
<div>
<label htmlFor="country">Country</label>
<select
id="country"
className={errors.country ? "input-error" : ""}
{...register("country", { required: "Country is required" })}
>
<option value="">Select a country</option>
<option value="US">United States</option>
<option value="CA">Canada</option>
<option value="UZ">Uzbekistan</option>
</select>
{errors.country && <span className="error">{errors.country.message}</span>}
</div>
{/* postal code format depends on the country field above */}
<div>
<label htmlFor="postalCode">Postal Code</label>
<input
id="postalCode"
type="text"
className={errors.postalCode ? "input-error" : ""}
{...register("postalCode", {
required: "Postal code is required",
validate: (value) => {
if (country === "US") {
return /^\d{5}$/.test(value) || "US postal codes must be 5 digits";
}
if (country === "CA") {
return (
/^[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d$/.test(value) ||
"Canadian postal codes must look like A1A 1A1"
);
}
return true;
},
})}
/>
{errors.postalCode && <span className="error">{errors.postalCode.message}</span>}
</div>
<div>
<label htmlFor="billingSameAsShipping">
<input id="billingSameAsShipping" type="checkbox" {...register("billingSameAsShipping")} />
Billing address same as shipping
</label>
</div>
{!billingSameAsShipping && (
<div>
<label htmlFor="billingAddress">Billing Address</label>
<input
id="billingAddress"
type="text"
className={errors.billingAddress ? "input-error" : ""}
{...register("billingAddress", {
validate: (value) => billingSameAsShipping || !!value || "Billing address is required",
})}
/>
{errors.billingAddress && <span className="error">{errors.billingAddress.message}</span>}
</div>
)}
<div>
<label htmlFor="paymentMethod">Payment Method</label>
<select
id="paymentMethod"
className={errors.paymentMethod ? "input-error" : ""}
{...register("paymentMethod", { required: "Select a payment method" })}
>
<option value="">Select a method</option>
<option value="card">Credit/Debit Card</option>
<option value="paypal">PayPal</option>
<option value="bank">Bank Transfer</option>
</select>
{errors.paymentMethod && <span className="error">{errors.paymentMethod.message}</span>}
</div>
{paymentMethod === "card" && (
<>
<div>
<label htmlFor="cardNumber">Card Number</label>
<input
id="cardNumber"
type="text"
className={errors.cardNumber ? "input-error" : ""}
{...register("cardNumber", {
validate: (value) => {
if (paymentMethod !== "card") return true;
if (!value) return "Card number is required";
return /^\d{4}\s?\d{4}\s?\d{4}\s?\d{4}$/.test(value) || "Card number must be 16 digits";
},
})}
/>
{errors.cardNumber && <span className="error">{errors.cardNumber.message}</span>}
</div>
<div>
<label htmlFor="cardExpiry">Expiry (MM/YY)</label>
<input
id="cardExpiry"
type="text"
placeholder="MM/YY"
className={errors.cardExpiry ? "input-error" : ""}
{...register("cardExpiry", {
validate: (value) => {
if (paymentMethod !== "card") return true;
if (!value) return "Expiry date is required";
const match = /^(\d{2})\/(\d{2})$/.exec(value);
if (!match) return "Use MM/YY format";
const [, month, year] = match;
const expiryDate = new Date(2000 + Number(year), Number(month));
const now = new Date();
return expiryDate > now || "Card has expired";
},
})}
/>
{errors.cardExpiry && <span className="error">{errors.cardExpiry.message}</span>}
</div>
<div>
<label htmlFor="cvv">CVV</label>
<input
id="cvv"
type="text"
className={errors.cvv ? "input-error" : ""}
{...register("cvv", {
validate: (value) => {
if (paymentMethod !== "card") return true;
if (!value) return "CVV is required";
return /^\d{3}$/.test(value) || "CVV must be 3 digits";
},
})}
/>
{errors.cvv && <span className="error">{errors.cvv.message}</span>}
</div>
</>
)}
<div>
<label htmlFor="couponCode">Coupon Code (optional)</label>
<input
id="couponCode"
type="text"
className={errors.couponCode ? "input-error" : ""}
{...register("couponCode", {
validate: (value) => !value || VALID_COUPONS.includes(value) || "Invalid coupon code",
})}
/>
{errors.couponCode && <span className="error">{errors.couponCode.message}</span>}
</div>
<div>
<label htmlFor="agreeToTerms">
<input
id="agreeToTerms"
type="checkbox"
{...register("agreeToTerms", {
validate: (value) => value === true || "You must agree to the terms",
})}
/>
I agree to the terms and conditions
</label>
{errors.agreeToTerms && <span className="error">{errors.agreeToTerms.message}</span>}
</div>
<button type="submit">Place Order</button>
</form>
);
}
export default CheckoutForm;
Three things are now concretely wrong with this file:
- Four separate
watch()calls drive re-renders on every keystroke, across four different fields (email,billingSameAsShipping,country,paymentMethod). - Business rules live inside JSX-adjacent callbacks, not as a single reviewable, testable unit. The coupon list check, the expiry-date math, the country-based postal code formats, all buried inline inside
register()calls, spread across a dozen different call sites. - To understand all the rules that make a submission valid, we have to read through the entire JSX tree, tracing each
registercall andwatch()ed variable one by one.
This is where Zod comes in. As we mentioned in Part 1, validation is what Zod does best, while state management and rendering stay with RHF.
Show code — Version 5: Zod schema replacing scattered validation
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
const VALID_COUPONS = ["SAVE10", "FREESHIP", "WELCOME20"];
const checkoutSchema = z
.object({
email: z.string().min(1, "Email is required").email("Enter a valid email address"),
confirmEmail: z.string().min(1, "Please confirm your email"),
shippingAddress: z.string().min(5, "Address seems too short"),
country: z.enum(["US", "CA", "UZ"], { errorMap: () => ({ message: "Country is required" }) }),
postalCode: z.string().min(1, "Postal code is required"),
billingSameAsShipping: z.boolean(),
billingAddress: z.string().optional(),
paymentMethod: z.enum(["card", "paypal", "bank"], {
errorMap: () => ({ message: "Select a payment method" }),
}),
cardNumber: z.string().optional(),
cardExpiry: z.string().optional(),
cvv: z.string().optional(),
couponCode: z.string().optional(),
agreeToTerms: z.literal(true, {
errorMap: () => ({ message: "You must agree to the terms" }),
}),
})
.superRefine((data, ctx) => {
if (data.confirmEmail !== data.email) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Emails do not match",
path: ["confirmEmail"],
});
}
if (data.country === "US" && !/^\d{5}$/.test(data.postalCode)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "US postal codes must be 5 digits",
path: ["postalCode"],
});
}
if (data.country === "CA" && !/^[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d$/.test(data.postalCode)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Canadian postal codes must look like A1A 1A1",
path: ["postalCode"],
});
}
if (!data.billingSameAsShipping && !data.billingAddress) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Billing address is required",
path: ["billingAddress"],
});
}
if (data.paymentMethod === "card") {
if (!data.cardNumber) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Card number is required", path: ["cardNumber"] });
} else if (!/^\d{4}\s?\d{4}\s?\d{4}\s?\d{4}$/.test(data.cardNumber)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Card number must be 16 digits", path: ["cardNumber"] });
}
if (!data.cardExpiry) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Expiry date is required", path: ["cardExpiry"] });
} else {
const match = /^(\d{2})\/(\d{2})$/.exec(data.cardExpiry);
if (!match) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Use MM/YY format", path: ["cardExpiry"] });
} else {
const [, month, year] = match;
const expiryDate = new Date(2000 + Number(year), Number(month));
if (expiryDate <= new Date()) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Card has expired", path: ["cardExpiry"] });
}
}
}
if (!data.cvv) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "CVV is required", path: ["cvv"] });
} else if (!/^\d{3}$/.test(data.cvv)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "CVV must be 3 digits", path: ["cvv"] });
}
}
if (data.couponCode && !VALID_COUPONS.includes(data.couponCode)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Invalid coupon code", path: ["couponCode"] });
}
});
function CheckoutForm() {
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm({
resolver: zodResolver(checkoutSchema),
mode: "onBlur",
});
const billingSameAsShipping = watch("billingSameAsShipping");
const paymentMethod = watch("paymentMethod");
const onSubmit = (data) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="email">Email</label>
<input id="email" type="email" className={errors.email ? "input-error" : ""} {...register("email")} />
{errors.email && <span className="error">{errors.email.message}</span>}
</div>
<div>
<label htmlFor="confirmEmail">Confirm Email</label>
<input
id="confirmEmail"
type="email"
className={errors.confirmEmail ? "input-error" : ""}
{...register("confirmEmail")}
/>
{errors.confirmEmail && <span className="error">{errors.confirmEmail.message}</span>}
</div>
<div>
<label htmlFor="shippingAddress">Shipping Address</label>
<input
id="shippingAddress"
type="text"
className={errors.shippingAddress ? "input-error" : ""}
{...register("shippingAddress")}
/>
{errors.shippingAddress && <span className="error">{errors.shippingAddress.message}</span>}
</div>
<div>
<label htmlFor="country">Country</label>
<select id="country" className={errors.country ? "input-error" : ""} {...register("country")}>
<option value="">Select a country</option>
<option value="US">United States</option>
<option value="CA">Canada</option>
<option value="UZ">Uzbekistan</option>
</select>
{errors.country && <span className="error">{errors.country.message}</span>}
</div>
<div>
<label htmlFor="postalCode">Postal Code</label>
<input
id="postalCode"
type="text"
className={errors.postalCode ? "input-error" : ""}
{...register("postalCode")}
/>
{errors.postalCode && <span className="error">{errors.postalCode.message}</span>}
</div>
<div>
<label htmlFor="billingSameAsShipping">
<input id="billingSameAsShipping" type="checkbox" {...register("billingSameAsShipping")} />
Billing address same as shipping
</label>
</div>
{!billingSameAsShipping && (
<div>
<label htmlFor="billingAddress">Billing Address</label>
<input
id="billingAddress"
type="text"
className={errors.billingAddress ? "input-error" : ""}
{...register("billingAddress")}
/>
{errors.billingAddress && <span className="error">{errors.billingAddress.message}</span>}
</div>
)}
<div>
<label htmlFor="paymentMethod">Payment Method</label>
<select id="paymentMethod" className={errors.paymentMethod ? "input-error" : ""} {...register("paymentMethod")}>
<option value="">Select a method</option>
<option value="card">Credit/Debit Card</option>
<option value="paypal">PayPal</option>
<option value="bank">Bank Transfer</option>
</select>
{errors.paymentMethod && <span className="error">{errors.paymentMethod.message}</span>}
</div>
{paymentMethod === "card" && (
<>
<div>
<label htmlFor="cardNumber">Card Number</label>
<input
id="cardNumber"
type="text"
className={errors.cardNumber ? "input-error" : ""}
{...register("cardNumber")}
/>
{errors.cardNumber && <span className="error">{errors.cardNumber.message}</span>}
</div>
<div>
<label htmlFor="cardExpiry">Expiry (MM/YY)</label>
<input
id="cardExpiry"
type="text"
placeholder="MM/YY"
className={errors.cardExpiry ? "input-error" : ""}
{...register("cardExpiry")}
/>
{errors.cardExpiry && <span className="error">{errors.cardExpiry.message}</span>}
</div>
<div>
<label htmlFor="cvv">CVV</label>
<input id="cvv" type="text" className={errors.cvv ? "input-error" : ""} {...register("cvv")} />
{errors.cvv && <span className="error">{errors.cvv.message}</span>}
</div>
</>
)}
<div>
<label htmlFor="couponCode">Coupon Code (optional)</label>
<input
id="couponCode"
type="text"
className={errors.couponCode ? "input-error" : ""}
{...register("couponCode")}
/>
{errors.couponCode && <span className="error">{errors.couponCode.message}</span>}
</div>
<div>
<label htmlFor="agreeToTerms">
<input id="agreeToTerms" type="checkbox" {...register("agreeToTerms")} />
I agree to the terms and conditions
</label>
{errors.agreeToTerms && <span className="error">{errors.agreeToTerms.message}</span>}
</div>
<button type="submit">Place Order</button>
</form>
);
}
export default CheckoutForm;
Every field's rules moved out of scattered register/validate closures into one schema, checkoutSchema. watch() calls dropped from four to two, billingSameAsShipping and paymentMethod are still watched, but only because the JSX needs to conditionally render those fields, not because validation needs their live value anymore. The register() calls in JSX are now nearly empty, no inline rules, no message strings, no per-field validate functions.
Shared types with Zod
Think of a monorepo. It's a single repository that hosts multiple applications, in the context of fullstack development, both the frontend and the backend. We want to reuse the same Zod schema we're using for form validation for API payload validation too. To do this, we need a shared workspace, a folder containing code shared between the client and the server, which we can import from just like an npm package:
my-checkout-app/
├── package.json
├── shared-schemas/
│ ├── package.json
│ └── checkoutSchema.ts
├── apps/
│ ├── frontend/
│ │ └── package.json
│ └── backend/
│ └── package.json
Root package.json — needs to declare both shared-schemas and apps/* as workspaces, since workspace linking is symmetric: it isn't just about publishing shared-schemas, it's about telling the package manager which packages might consume other workspace packages, so it can set up the right symlinks for each one.
{
"name": "my-checkout-app",
"private": true,
"workspaces": [
"shared-schemas",
"apps/*"
]
}
shared-schemas/checkoutSchema.ts
import { z } from "zod";
export const VALID_COUPONS = ["SAVE10", "FREESHIP", "WELCOME20"];
export const checkoutSchema = z
.object({
email: z.string().min(1, "Email is required").email("Enter a valid email address"),
confirmEmail: z.string().min(1, "Please confirm your email"),
shippingAddress: z.string().min(5, "Address seems too short"),
country: z.enum(["US", "CA", "UZ"]),
postalCode: z.string().min(1, "Postal code is required"),
paymentMethod: z.enum(["card", "paypal", "bank"]),
couponCode: z.string().optional(),
agreeToTerms: z.literal(true, {
errorMap: () => ({ message: "You must agree to the terms" }),
}),
})
.superRefine((data, ctx) => {
if (data.confirmEmail !== data.email) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Emails do not match",
path: ["confirmEmail"],
});
}
if (data.couponCode && !VALID_COUPONS.includes(data.couponCode)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid coupon code",
path: ["couponCode"],
});
}
});
shared-schemas/package.json
{
"name": "@my-checkout-app/shared-schemas",
"version": "1.0.0",
"main": "checkoutSchema.ts",
"types": "checkoutSchema.ts",
"dependencies": {
"zod": "^3.23.0"
}
}
Frontend usage — apps/frontend/CheckoutForm.tsx:
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { checkoutSchema } from "@my-checkout-app/shared-schemas";
function CheckoutForm() {
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(checkoutSchema),
});
const onSubmit = (data) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* fields, same as before */}
</form>
);
}
Backend usage — apps/backend/routes/checkout.ts:
import express from "express";
import { checkoutSchema } from "@my-checkout-app/shared-schemas";
const router = express.Router();
router.post("/checkout", (req, res) => {
const result = checkoutSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error.issues });
}
console.log(result.data);
res.status(200).json({ message: "Order placed" });
});
export default router;
Both the frontend form and the backend route validate against the exact same rules, defined in exactly one place. If the coupon list changes or a field's minimum length changes, it's a single edit in checkoutSchema.ts, not two edits kept in sync by hand.
TypeScript
Before seeing why Zod pairs so well with TypeScript, it helps to be clear on what TypeScript actually is: a compile-time tool. When your code runs in the browser, none of your type annotations exist anymore, they're erased during compilation down to plain JavaScript.
TypeScript source → compiled/stripped → plain JavaScript → runs in the browser
That means a type like interface CheckoutFormData { email: string } guarantees nothing once the code is actually running. If req.body.email comes back as 123 from a malformed request, TypeScript can't stop that at runtime, it already did its job at compile time and stepped aside. This is exactly why runtime validation still matters even in a fully typed codebase: types protect you while you're writing code, not while your code is running against real, unpredictable input.
This is where z.infer becomes genuinely satisfying to use. Instead of hand-writing an interface that mirrors your validation rules and hoping the two never drift apart, you derive the type directly from the schema that's already doing the runtime check:
import { z } from "zod";
import { checkoutSchema } from "@my-checkout-app/shared-schemas";
type CheckoutFormData = z.infer<typeof checkoutSchema>;
// {
// email: string;
// confirmEmail: string;
// shippingAddress: string;
// country: "US" | "CA" | "UZ";
// postalCode: string;
// paymentMethod: "card" | "paypal" | "bank";
// couponCode?: string;
// agreeToTerms: true;
// }
That's the entire type definition. No separate interface to maintain, no risk of the type and the validation rule disagreeing with each other, because they're now mechanically the same object. Delete a field from the schema, and the type updates. Add a .min() constraint, and any downstream code that assumed a looser shape gets flagged by the compiler.
function processOrder(data: CheckoutFormData) {
// TypeScript now knows data.country is "US" | "CA" | "UZ", not just any string
// and data.agreeToTerms is always literally `true`, never `false`
console.log(`Processing order for ${data.fullName}`);
}
React Hook Form and Zod don't compete, they solve different problems. React Hook Form manages how users interact with a form, while Zod defines what valid data looks like. Together, they let you build forms that stay fast, maintainable, and type-safe as requirements grow.