RHF + Zod
Integrate Zod schemas with react-hook-form via @hookform/resolvers/zod for fully typed, schema-driven forms.
Recipe
Quick-reference recipe card — copy-paste ready.
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const Schema = z.object({
email: z.string().email("Invalid email"),
password: z.string().min(8, "At least 8 characters"),
});
type FormData = z.infer<typeof Schema>;
function LoginForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(Schema),
defaultValues: { email: "", password: "" },
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input {...register("email")} />
{errors.email && <p>{errors.email.message}</p>}
<input type="password" {...register("password")} />
{errors.password && <p>{errors.password.message}</p>}
<button type="submit">Log in</button>
</form>
);
}When to reach for this: When you want schema-defined validation rules (Zod) powering react-hook-form's performant field-level error display.
Working Example
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const ProfileSchema = z
.object({
username: z
.string()
.min(3, "At least 3 characters")
.max(20)
.regex(/^[a-z0-9_]+$/, "Lowercase letters, numbers, underscores only"),
displayName: z.string().min(1, "Required"),
bio: z.string().max(500).optional(),
website: z.string().url("Invalid URL").optional().or(z.literal("")),
newPassword: z.string().min(8).optional().or(z.literal("")),
confirmPassword: z.string().optional().or(z.literal("")),
})
.refine(
(data) => {
if (data.newPassword && data.newPassword !== data.confirmPassword) return false;
return true;
},
{ message: "Passwords must match", path: ["confirmPassword"] }
);
type ProfileFormData = z.infer<typeof ProfileSchema>;
export function ProfileEditor() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isDirty },
reset,
} = useForm<ProfileFormData>({
resolver: zodResolver(ProfileSchema),
defaultValues: {
username: "johndoe",
displayName: "John Doe",
bio: "",
website: "",
newPassword: "",
confirmPassword: "",
},
mode: "onBlur",
});
async function onSubmit(data: ProfileFormData) {
await new Promise((r) => setTimeout(r, 1000)); // simulate API
console.log("Saved:", data);
reset(data);
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="max-w-md space-y-4">
<div>
<label className="block text-sm font-medium">Username</label>
<input {...register("username")} className="w-full rounded border p-2" />
{errors.username && <p className="text-sm text-red-600">{errors.username.message}</p>}
</div>
<div>
<label className="block text-sm font-medium">Display Name</label>
<input {...register("displayName")} className="w-full rounded border p-2" />
{errors.displayName && <p className="text-sm text-red-600">{errors.displayName.message}</p>}
</div>
<div>
<label className="block text-sm font-medium">Bio</label>
<textarea {...register("bio")} rows={3} className="w-full rounded border p-2" />
{errors.bio && <p className="text-sm text-red-600">{errors.bio.message}</p>}
</div>
<div>
<label className="block text-sm font-medium">Website</label>
<input {...register("website")} placeholder="https://..." className="w-full rounded border p-2" />
{errors.website && <p className="text-sm text-red-600">{errors.website.message}</p>}
</div>
<fieldset className="rounded border p-3">
<legend className="px-1 text-sm font-medium">Change Password (optional)</legend>
<div className="space-y-2">
<input {...register("newPassword")} type="password" placeholder="New password" className="w-full rounded border p-2" />
{errors.newPassword && <p className="text-sm text-red-600">{errors.newPassword.message}</p>}
<input {...register("confirmPassword")} type="password" placeholder="Confirm" className="w-full rounded border p-2" />
{errors.confirmPassword && <p className="text-sm text-red-600">{errors.confirmPassword.message}</p>}
</div>
</fieldset>
<button
type="submit"
disabled={isSubmitting || !isDirty}
className="rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
>
{isSubmitting ? "Saving..." : "Save Profile"}
</button>
</form>
);
}What this demonstrates:
zodResolverwiring touseForm- Object-level
.refine()for cross-field validation with path targeting - Optional fields that accept empty strings (
.or(z.literal(""))) mode: "onBlur"for validate-on-blur behavior- Dirty tracking and submit-state management
Deep Dive
How It Works
zodResolver(schema)returns a function matching RHF'sResolvertype- On form submission (or mode-triggered validation), RHF calls the resolver with all field values
- The resolver runs
schema.safeParse(values)and mapsZodError.issuesto RHF'sFieldErrorsformat - RHF then distributes errors to the correct fields via
errors.fieldName - Schema-level
.refine()errors map to thepathyou specify, or torootif no path is given
Variations
Accessing root-level errors:
const { formState: { errors } } = useForm({ resolver: zodResolver(schema) });
// Root error from a .refine() without a path
errors.root?.message;Resolver with custom error mapping:
useForm({
resolver: zodResolver(Schema, {
// Pass Zod options
errorMap: (issue, ctx) => ({
message: customMessages[issue.code] ?? ctx.defaultError,
}),
}),
});Schema with async validation:
const Schema = z.object({
username: z.string().refine(async (val) => {
const available = await checkUsername(val);
return available;
}, "Username taken"),
});
// zodResolver handles async automatically
useForm({ resolver: zodResolver(Schema) });TypeScript Notes
// The generic type flows from the schema
const Schema = z.object({ name: z.string() });
type T = z.infer<typeof Schema>;
// useForm is typed by the generic, not the resolver
const form = useForm<T>({ resolver: zodResolver(Schema) });
// If types mismatch between z.infer and the generic, TS catches it
const BadSchema = z.object({ email: z.string() });
// useForm<T>({ resolver: zodResolver(BadSchema) }) — no TS error at resolver level
// but register("name") will still be type-safe against T
// Best practice: derive the type from the schema
type FormData = z.infer<typeof Schema>;
const form = useForm<FormData>({ resolver: zodResolver(Schema) });Gotchas
-
Schema and generic type must stay in sync — If you change the schema but not the
useFormgeneric (or vice versa), validation and types diverge silently. Fix: Always derive the form type withz.infer<typeof Schema>. -
Optional fields with empty strings — HTML inputs submit
""for empty fields, butz.string().optional()expectsundefined. Fix: Use.optional().or(z.literal(""))or preprocess empty strings toundefined. -
Transforms not reflected in form values —
zodResolverreturns the parsed (transformed) data toonSubmit, but the form fields still show the raw input values. Fix: This is expected; transforms apply only to the data passed toonSubmit. -
Cross-field errors need
path— Object-level.refine()withoutpathputs the error onerrors.root, which is easy to miss in the UI. Fix: Always specifypath: ["fieldName"].
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Yup resolver | Legacy codebase using Yup schemas | Starting fresh (Zod has better inference) |
| Valibot resolver | You need minimal bundle size | You need Zod's ecosystem |
| Built-in RHF validation | Very simple rules (required, minLength) only | You want centralized schema validation |
| Server-only validation | Forms submit via server actions with no client JS | You need instant field-level feedback |
FAQs
What package connects Zod schemas to react-hook-form?
The @hookform/resolvers package provides zodResolver, which you pass to useForm({ resolver: zodResolver(Schema) }).
How do you derive the form's TypeScript type from a Zod schema?
const Schema = z.object({ email: z.string().email() });
type FormData = z.infer<typeof Schema>;
const form = useForm<FormData>({ resolver: zodResolver(Schema) });What does zodResolver do internally when the form is submitted?
- It calls
schema.safeParse(values)with all field values - On failure, it maps
ZodError.issuesto RHF'sFieldErrorsformat - RHF then distributes errors to the correct fields via
errors.fieldName
How do you handle optional fields that HTML inputs submit as empty strings?
Use .optional().or(z.literal("")) on the schema field. Without this, z.string().optional() expects undefined, but HTML inputs send "".
How do you validate that two fields match (e.g., password confirmation)?
const Schema = z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).refine((d) => d.password === d.confirmPassword, {
message: "Passwords must match",
path: ["confirmPassword"],
});What does mode: "onBlur" do in useForm?
It triggers validation when a field loses focus instead of only on submit, giving users feedback as they move between fields.
Where does a .refine() error appear if you omit the path option?
It lands on errors.root, which is easy to miss in the UI. Always specify path: ["fieldName"] to attach the error to the correct field.
Gotcha: What happens if your Zod schema and useForm generic type get out of sync?
Validation and types diverge silently. The resolver validates against the schema shape, but register() is type-checked against the generic. Always derive the type with z.infer<typeof Schema> to keep them in sync.
Gotcha: Does a Zod .transform() change the values displayed in form fields?
No. zodResolver returns the transformed data only to the onSubmit handler. The form fields still display the raw input values. This is expected behavior.
How do you use async validation (e.g., checking username availability) with zodResolver?
const Schema = z.object({
username: z.string().refine(async (val) => {
return await checkUsername(val);
}, "Username taken"),
});
// zodResolver handles async automatically
useForm({ resolver: zodResolver(Schema) });How do you pass a custom error map to zodResolver?
useForm({
resolver: zodResolver(Schema, {
errorMap: (issue, ctx) => ({
message: customMessages[issue.code] ?? ctx.defaultError,
}),
}),
});TypeScript: Why should you avoid manually writing an interface for form data?
- Manual interfaces can drift from the schema, causing runtime validation to disagree with compile-time types
z.infer<typeof Schema>is always in sync with the schema definition- It eliminates an entire class of bugs where a field is added to one but not the other
TypeScript: Does the zodResolver itself catch type mismatches between the schema and the useForm generic?
No. TypeScript does not flag a mismatch at the resolver level. The type safety comes from register("fieldName") being checked against the generic. This is why deriving the type from the schema is critical.
Related
- React Hook Form — RHF core concepts
- Zod Basics — schema definition
- shadcn Form — shadcn + RHF + Zod integration
- Form Patterns Basic — common form patterns