Cross-Field Validation with Zod and React Hook Form
Form validation rarely stands alone on a single field. Almost every production form has rules involving more than one field at once — a password confirmation must match the password, an end date must be greater than a start date, or a company name is only required if the user selects a business account type. These patterns are called cross-field validation, and this is one of the validation parts most often implemented incorrectly. This article thoroughly discusses how to handle cross-field validation using Zod and React Hook Form, from simple cases to scalable schema architecture for complex forms.
What Is Cross-Field Validation?
Cross-field validation is a validation rule that can’t be determined only from one field’s value — it needs values from other fields to decide whether an input is valid or not.
Regular validation:
password → minimum 8 characters (only needs the password value)
Cross-field validation:
confirmPassword → must match password (needs two field values)
endDate → must be greater than startDate (needs two field values)
companyName → required if isCompany = true (value depends on another field's state)
Zod provides three mechanisms to handle this, each suitable for different situations:
flowchart TD
A{How many conditions<br/>need checking?} -- One condition --> B{Need multiple<br/>errors at once?}
B -- No --> C[".refine()"]
B -- Yes --> D[".superRefine()"]
A -- Many conditions --> E{Form structure<br/>differs per value?}
E -- Yes --> F["discriminatedUnion()"]
E -- No --> D.refine() — Validating One Condition
Use .refine() when there’s only one cross-field rule to check and it produces only one error message.
Password Confirmation
This is the most common case: the confirmPassword field must be identical to password.
// ANTI-PATTERN: validation done in the component, the schema knows nothing
const onSubmit = (data) => {
if (data.password !== data.confirmPassword) {
setError("confirmPassword", { message: "Passwords don't match" });
return;
}
// submit process...
};
// CORRECT: validation lives in the schema, the component stays clean
import { z } from "zod";
const loginSchema = z
.object({
password: z.string().min(8, "Minimum 8 characters"),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Password confirmation doesn't match",
path: ["confirmPassword"],
});
The path parameter is the key here. Without it, the error appears at the root form level and React Hook Form doesn’t know which field to mark red. With path: ["confirmPassword"], the error is directed exactly to the intended field.
Date Range Validation
The same pattern applies to date ranges — the error is directed to the “second” field considered wrong:
const bookingSchema = z
.object({
startDate: z.date({ required_error: "Start date is required" }),
endDate: z.date({ required_error: "End date is required" }),
})
.refine((data) => data.endDate > data.startDate, {
message: "End date must be after the start date",
path: ["endDate"],
});
.refine()only produces one error per validation. If you need to display several errors at once from one cross-field validation,.refine()isn’t enough — use.superRefine()instead.
.superRefine() — Full Control
Use .superRefine() when there are many conditions to check, or when the validation needs to produce several errors on different fields simultaneously.
Conditional Required Fields
Scenario: companyName is only required if isCompany is true.
// ANTI-PATTERN: companyName always required in the schema
const schema = z.object({
isCompany: z.boolean(),
companyName: z.string().min(1), // always fails when empty, even though it's not always needed
});
// CORRECT: companyName optional in the schema, conditional validation in superRefine
const registrationSchema = z
.object({
isCompany: z.boolean(),
companyName: z.string().optional(),
taxId: z.string().optional(),
})
.superRefine((data, ctx) => {
if (data.isCompany) {
if (!data.companyName) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Company name is required for business accounts",
path: ["companyName"],
});
}
if (!data.taxId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Tax ID is required for business accounts",
path: ["taxId"],
});
}
}
});
With .superRefine(), both errors — companyName and taxId — can appear together. Users don’t need to submit twice just to find all missing fields.
Complex Password Validation
.superRefine() is also useful when validating one field with many interrelated rules:
const passwordChangeSchema = z
.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(8),
confirmPassword: z.string(),
})
.superRefine((data, ctx) => {
// Make sure the new password differs from the old one
if (data.newPassword === data.currentPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "New password must not be the same as the old password",
path: ["newPassword"],
});
}
// Make sure the confirmation matches
if (data.newPassword !== data.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Password confirmation doesn't match",
path: ["confirmPassword"],
});
}
});
sequenceDiagram
participant User
participant RHF as React Hook Form
participant Zod as Zod Schema
participant UI
User->>RHF: submit form
RHF->>Zod: validate all fields
Zod->>Zod: run superRefine
Zod-->>RHF: ZodIssue array (can be > 1)
RHF->>RHF: distribute errors to each field
RHF-->>UI: render errors on confirmPassword & newPassword
UI-->>User: show all errors at oncediscriminatedUnion() — Scalable Architecture
When one field determines the entire form structure, .refine() and .superRefine() start to feel like workarounds. The solution is z.discriminatedUnion() — a union type where one field acts as a discriminator determining which schema shape applies.
Case: Payment Methods
// ANTI-PATTERN: one big schema with all fields optional
const schema = z.object({
paymentMethod: z.enum(["credit_card", "bank_transfer", "ewallet"]),
cardNumber: z.string().optional(), // only for credit_card
bankAccount: z.string().optional(), // only for bank_transfer
ewalletPhone: z.string().optional(), // only for ewallet
}).superRefine((data, ctx) => {
// conditional logic grows as payment methods grow
if (data.paymentMethod === "credit_card" && !data.cardNumber) {
ctx.addIssue({ ... });
}
// ... and so on, the longer it gets the harder to maintain
});
// CORRECT: discriminatedUnion — each method has its own schema
const paymentSchema = z.discriminatedUnion("paymentMethod", [
z.object({
paymentMethod: z.literal("credit_card"),
cardNumber: z.string().min(16, "Invalid card number").max(16),
cardExpiry: z.string().regex(/^\d{2}\/\d{2}$/, "MM/YY format"),
cardCvv: z.string().length(3, "CVV must be 3 digits"),
}),
z.object({
paymentMethod: z.literal("bank_transfer"),
bankCode: z.string().min(1, "Choose the destination bank"),
bankAccount: z.string().min(10, "Invalid account number"),
accountName: z.string().min(1, "Account holder name is required"),
}),
z.object({
paymentMethod: z.literal("ewallet"),
ewalletProvider: z.enum(["gopay", "ovo", "dana"]),
ewalletPhone: z.string().regex(/^08\d{8,11}$/, "Invalid phone number format"),
}),
]);
Every time paymentMethod changes, Zod automatically knows which schema to use for validation. No optional() workarounds, no manual conditional logic growing longer as options increase.
flowchart TD
A[paymentMethod] --> B{Discriminator value}
B -- "credit_card" --> C["Schema: cardNumber<br/>cardExpiry, cardCvv"]
B -- "bank_transfer" --> D["Schema: bankCode<br/>bankAccount, accountName"]
B -- "ewallet" --> E["Schema: ewalletProvider<br/>ewalletPhone"]
C --> F[Validation runs<br/>per the active schema]
D --> F
E --> FIntegration with React Hook Form
Zod connects to React Hook Form through zodResolver from the @hookform/resolvers package:
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const schema = z
.object({
password: z.string().min(8),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Password confirmation doesn't match",
path: ["confirmPassword"],
});
type FormValues = z.infer<typeof schema>;
function ChangePasswordForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
mode: "onChange", // validation runs on every value change
});
const onSubmit = (data: FormValues) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("password")} type="password" />
{errors.password && <p>{errors.password.message}</p>}
<input {...register("confirmPassword")} type="password" />
{errors.confirmPassword && <p>{errors.confirmPassword.message}</p>}
<button type="submit">Save</button>
</form>
);
}
Dynamic Fields and useWatch
For forms where the displayed fields change based on another field’s value, use useWatch so the component reacts to changes:
import { useForm, useWatch } from "react-hook-form";
function RegistrationForm() {
const { register, control, handleSubmit } = useForm({
resolver: zodResolver(registrationSchema),
});
// the component re-renders only when isCompany changes
const isCompany = useWatch({ control, name: "isCompany" });
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("isCompany")} type="checkbox" />
{isCompany && (
<>
<input {...register("companyName")} placeholder="Company Name" />
<input {...register("taxId")} placeholder="Tax ID" />
</>
)}
<button type="submit">Register</button>
</form>
);
}
Fields not rendered are still validated by Zod. This means ifcompanyNameis displayed conditionally in the UI but the schema doesn’t use.optional(), validation will still fail even though the field isn’t visible. Always make conditional fields.optional()in the schema, then set their requirement via.superRefine()ordiscriminatedUnion.
Approach Comparison
| Need | Approach | Reason |
|---|---|---|
| One simple cross-field rule | .refine() | Concise, enough for one condition |
| Many conditions, possibly multiple errors | .superRefine() | Full control over each issue |
| Form structure differs per enum value | discriminatedUnion() | Separate schema per case, no workarounds |
| Very dynamic form + many options | discriminatedUnion() + useWatch | Scalable and type-safe |
Anti-Patterns to Avoid
// ✗ Cross-field error without a path — appears at the root, not on the field
const schema = z.object({ ... }).refine(
(data) => data.endDate > data.startDate,
{ message: "Invalid date" } // no path!
);
// ✓ Always include a path so the error appears on the right field
const schema = z.object({ ... }).refine(
(data) => data.endDate > data.startDate,
{ message: "End date must be after the start date", path: ["endDate"] }
);
// ✗ Cross-field validation done in the component handler
const onSubmit = (data) => {
if (data.password !== data.confirmPassword) {
setError("confirmPassword", ...); // logic scattered everywhere
}
};
// ✓ All validation lives in the Zod schema
const schema = z.object({ ... }).refine(...);
// ✗ Conditional fields not using .optional()
const schema = z.object({
isCompany: z.boolean(),
companyName: z.string().min(1), // always fails when isCompany = false
});
// ✓ optional in the schema, its requirement set in superRefine
const schema = z.object({
isCompany: z.boolean(),
companyName: z.string().optional(),
}).superRefine((data, ctx) => {
if (data.isCompany && !data.companyName) {
ctx.addIssue({ ... });
}
});
// ✗ Using superRefine for everything including cases that could use discriminatedUnion
// ✓ Use discriminatedUnion when one field determines the entire form shape
Cross-Field Validation Implementation Checklist
SCHEMA:
□ Every .refine() has a clear path parameter
□ Conditional fields use .optional() in the main schema
□ Needs of > 1 error at once use .superRefine()
□ Enum/choice-based forms use discriminatedUnion
RHF INTEGRATION:
□ zodResolver used as the resolver in useForm
□ "onChange" or "onBlur" mode adjusted to the desired UX
□ useWatch used for conditionally displayed fields
□ errors from formState displayed on each related field
ARCHITECTURE:
□ Validation lives in the schema, not in components or handlers
□ Form types derived from z.infer<typeof schema>
□ No manual setError except for server-side errors
Summary
.refine()for one simple cross-field rule — concise and easy to read, but only produces one error..superRefine()for complex validation with many conditions — usectx.addIssue()to add errors to any fields simultaneously.pathis mandatory in every.refine()andctx.addIssue()so React Hook Form knows which field to mark as having an error.- Conditional fields must be
.optional()in the main schema — set their requirement programmatically in.superRefine().discriminatedUnion()is the best choice when one field determines the entire form shape — cleaner, type-safe, and easy to extend.- Don’t validate cross-field in components — logic scattered across form handlers is hard to maintain and hard to test.
useWatchmakes a component reactive to a specific field’s value without causing the entire form to re-render.