Jul 14, 2026 · 8 min read ·
Why React Hook Form and Zod Work Better Together (Part 1)
Introduction
One of the most painful parts of writing large forms is handling business logic alongside validation and performance. Whether you used the useReducer hook or a separate useState for each input, you ended up writing a lot of code. And we shouldn't forget UX edge cases like touched/dirty input states, or validation rules that depend on related inputs.
As most code is written by AI agents these days, we're still responsible for what they produce. Addy Osmani makes this point in his article "Agentic Engineering": we should treat AI as a coworker whose output we judge critically, not as a tech lead whose word we accept without question. Generating bad code has become very cheap, but the long-term cost of maintaining it is higher than ever. That's exactly why readability and ease of editing matter more now, especially as codebases grow larger thanks to AI-assisted development. This matters for the AI agents themselves too: a maintainable, readable codebase is exactly what lets an agent add new features safely, as Matt Pocock argues in his talk "Software Fundamentals Matter More Than Ever".
So what are Zod and React Hook Form, briefly? Zod is a library that helps you define, validate, and enforce rules for your data. React Hook Form is a library that manages your form's state and keeps it performant. Both make handling forms easier by taking care of state management, validation, and edge cases, with less boilerplate.
Before going further, it helps to be clear on what each one is actually responsible for:
React Hook Form handles:
- Managing form state (what's currently typed into each field)
- Tracking dirty and touched state per field
- Avoiding unnecessary re-renders while typing
- Wiring up submission and calling your handler once everything passes
- Registering inputs so React can read their values
Zod handles:
- Defining what counts as valid data for each field
- Cross-field validation rules (comparing or conditioning one field on another)
- Transforming or coercing data into the shape you actually want
- Inferring a TypeScript type directly from the validation rules
- Being reusable outside the form entirely, e.g. validating the same payload on your backend
Neither library replaces the other. React Hook Form manages how the form behaves. Zod defines what counts as valid data. That distinction is the reason this article exists.
What this article is not
This article won't teach you the fundamentals of these libraries or how to use them; there are already dozens of articles on that. Instead, it answers a different question: why should we consider using them in our projects, what problems do they actually solve, and what do we gain by using both together, with example code along the way.
Building a form: base example
Let's say we're building a checkout form as part of a product shipping feature. Initially, we're asked to create a simple form with 3 fields: email for order confirmation, shipping address, and a card number for payment. The last two are plain text fields for now.
Show code — Version 1: Native HTML Validation
function CheckoutForm() {
const handleSubmit = (e) => {
e.preventDefault();
const formData = new FormData(e.target);
console.log(Object.fromEntries(formData));
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
placeholder="jane@example.com"
required
/>
</div>
<div>
<label htmlFor="shippingAddress">Shipping Address</label>
<input
id="shippingAddress"
name="shippingAddress"
type="text"
placeholder="123 Main St, Springfield"
required
minLength={5}
/>
</div>
<div>
<label htmlFor="cardNumber">Card Number</label>
<input
id="cardNumber"
name="cardNumber"
type="text"
placeholder="1234 5678 9012 3456"
required
pattern="\d{4}\s?\d{4}\s?\d{4}\s?\d{4}"
/>
</div>
<button type="submit">Place Order</button>
</form>
);
}
export default CheckoutForm;
Instead of useState or useReducer hooks, we're using the FormData class to handle form state. This gives us a smaller footprint by avoiding a separate useState for each input, and it avoids the re-render that would normally follow a state change on every keystroke. We're also using simple, minimalistic validation via input attributes like required, minLength, and pattern. So far the code looks clean and simple, thanks to a very modest set of requirements. However, there are some drawbacks to list:
- No cross-field validation. For example, what if we add a checkbox that determines whether it's home delivery or in-store pickup? If delivery is checked but the address field is empty, we'd need to show an error message about this, and there's no attribute that can express that relationship.
- No custom error messages. The error is the browser's own tooltip, which looks different per browser.
- No control over the language of built-in error messages. The browser decides.
Show code — Version 2: Manual JavaScript Validation
import { useState } from "react";
function CheckoutForm() {
const [email, setEmail] = useState("");
const [shippingAddress, setShippingAddress] = useState("");
const [cardNumber, setCardNumber] = useState("");
const [errors, setErrors] = useState({});
const validate = () => {
const newErrors = {};
if (!email) {
newErrors.email = "Email is required";
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
newErrors.email = "Enter a valid email address";
}
if (!shippingAddress) {
newErrors.shippingAddress = "Shipping address is required";
} else if (shippingAddress.length < 5) {
newErrors.shippingAddress = "Address seems too short";
}
if (!cardNumber) {
newErrors.cardNumber = "Card number is required";
} else if (!/^\d{4}\s?\d{4}\s?\d{4}\s?\d{4}$/.test(cardNumber)) {
newErrors.cardNumber = "Card number must be 16 digits";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e) => {
e.preventDefault();
if (!validate()) return;
console.log({ email, shippingAddress, cardNumber });
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
placeholder="jane@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
{errors.email && <span className="error">{errors.email}</span>}
</div>
<div>
<label htmlFor="shippingAddress">Shipping Address</label>
<input
id="shippingAddress"
type="text"
placeholder="123 Main St, Springfield"
value={shippingAddress}
onChange={(e) => setShippingAddress(e.target.value)}
/>
{errors.shippingAddress && <span className="error">{errors.shippingAddress}</span>}
</div>
<div>
<label htmlFor="cardNumber">Card Number</label>
<input
id="cardNumber"
type="text"
placeholder="1234 5678 9012 3456"
value={cardNumber}
onChange={(e) => setCardNumber(e.target.value)}
/>
{errors.cardNumber && <span className="error">{errors.cardNumber}</span>}
</div>
<button type="submit">Place Order</button>
</form>
);
}
export default CheckoutForm;
This solves Version 1's biggest gaps: custom messages, full control over styling, and support for cross-field logic. The code above looks complete now, since it has:
- Custom error messages with custom styling
- Support for cross-field logic (e.g. comparing two state variables inside
validate)
But it still has real drawbacks:
- Re-renders on every keystroke. Each
onChangecalls itssetState, re-rendering the whole component on every character typed, in every field. This is negligible at 3 fields, but becomes a real problem once we hit 10+ fields. - Boilerplate scales linearly with field count. Every new field needs four things: a
useStatepair, anonChangehandler, a branch insidevalidate, and an{errors.x && <span>}block. It's easy to forget one of these when adding a field. - Validation logic lives separately from the field it validates. To know what rules apply to
cardNumber, you have to scroll to a different function (validate) rather than seeing the rule right next to the<input>.
Introducing React Hook Form
Show code — Version 3: React Hook Form
import { useForm } from "react-hook-form";
function CheckoutForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm({ mode: "onBlur" });
const onSubmit = (data) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
placeholder="jane@example.com"
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="shippingAddress">Shipping Address</label>
<input
id="shippingAddress"
type="text"
placeholder="123 Main St, Springfield"
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="cardNumber">Card Number</label>
<input
id="cardNumber"
type="text"
placeholder="1234 5678 9012 3456"
className={errors.cardNumber ? "input-error" : ""}
{...register("cardNumber", {
required: "Card number is required",
pattern: {
value: /^\d{4}\s?\d{4}\s?\d{4}\s?\d{4}$/,
message: "Card number must be 16 digits",
},
})}
/>
{errors.cardNumber && <span className="error">{errors.cardNumber.message}</span>}
</div>
<button type="submit">Place Order</button>
</form>
);
}
export default CheckoutForm;
React Hook Form addresses the drawbacks from both previous versions at once:
- No
useState: less boilerplate to handle, and no re-renders on every keystroke (Version 2's issue). RHF stores values viaref, connecting to uncontrolled inputs, rather than putting them in React state. That's the actual mechanism behind the win: typing into one field doesn't force the entire form component to re-render, since React never sees a state change on each keystroke, only on a validation-state change. - Custom error messages (Version 1's issue)
- Validation logic lives inside
register, next to the input it belongs to, making it easier to follow (Version 2's issue)
Adding cross-field rules
Two real cross-field cases come up almost immediately in a checkout form: confirming the email address matches, and letting billing address default to shipping unless the customer says otherwise.
Show code — Version 3 extended: React Hook Form with cross-field logic
import { useForm } from "react-hook-form";
function CheckoutForm() {
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm({ mode: "onBlur" });
const email = watch("email");
const billingSameAsShipping = watch("billingSameAsShipping");
const onSubmit = (data) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
placeholder="jane@example.com"
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"
placeholder="123 Main St, Springfield"
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="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="cardNumber">Card Number</label>
<input
id="cardNumber"
type="text"
placeholder="1234 5678 9012 3456"
className={errors.cardNumber ? "input-error" : ""}
{...register("cardNumber", {
required: "Card number is required",
pattern: {
value: /^\d{4}\s?\d{4}\s?\d{4}\s?\d{4}$/,
message: "Card number must be 16 digits",
},
})}
/>
{errors.cardNumber && <span className="error">{errors.cardNumber.message}</span>}
</div>
<button type="submit">Place Order</button>
</form>
);
}
export default CheckoutForm;
This works, but the seams are already showing:
watchbrings re-renders back. Every keystroke inemailnow re-rendersCheckoutForm, sincewatch("email")subscribes the whole component to that field's changes soconfirmEmail'svalidatecan read it. The performance win from the previous section starts eroding the moment cross-field logic needs a shared, live value.- The conditional-requirement logic reads backwards.
billingSameAsShipping || !!value || "message"is doing "return true if the checkbox is checked, OR if there's a value, OR else show the error." That inverted structure is easy to get subtly wrong, and easy for an AI agent to generate incorrectly too, since the intent isn't obvious at a glance. - The rule is split across two places. The JSX conditional decides whether
billingAddresseven renders; thevalidatefunction separately decides whether it's valid once shown. Both have to be kept in sync by hand.
In Part 2, requirements keep growing, plain React Hook Form starts to buckle under its own weight, and Zod steps in to take over validation entirely.