43.6 Zod with React Hook Form and Other Libraries
Right, so you’ve got your beautiful TypeScript types and you’ve got your form. In a perfect world, they’d just shake hands and get along. But we don’t live in that world. We live in a world where any is the default and users will, with shocking creativity, find a way to enter their birthday as "tomorrow" in a field you meant for a credit card number.
This is where the marriage of React Hook Form (RHF) and Zod stops being a cute idea and starts being a non-negotiable part of your professional sanity. RHF handles the form state management circus—dirty fields, re-renders, submissions—with astonishing performance. Zod handles being the immovable, type-safe bouncer at the door. Together, they ensure the data that enters your form is the data your logic expects. No more if (someStringMaybe) garbage.
The Basic Integration: useForm and zodResolver
The magic glue here is the @hookform/resolvers package. You didn’t think the RHF team would make you wire this up from scratch, did they? Well, they didn’t. You need to install it:
npm install @hookform/resolvers
Now, here’s the core setup. You’ll define your Zod schema exactly as you would normally, and then feed it to useForm.
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
// Define your schema with clear, user-focused error messages
const formSchema = z.object({
email: z.string().email("Seriously? That's not an email."),
password: z.string().min(8, "Your password is shorter than a TikTok attention span."),
acceptTerms: z.literal(true, {
errorMap: () => ({ message: "You must accept the terms, we pinky promise." }),
}),
});
type FormValues = z.infer<typeof formSchema>;
export function LoginForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(formSchema), // This is the key line
defaultValues: {
email: '',
password: '',
},
});
const onSubmit = (data: FormValues) => {
// The beauty: 'data' is now 100% type-safe and validated.
// You can send this to your API with confidence.
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{errors.email?.message && <span>{errors.email.message}</span>}
<input type="password" {...register('password')} />
{errors.password?.message && <span>{errors.password.message}</span>}
<input type="checkbox" {...register('acceptTerms')} />
{errors.acceptTerms?.message && <span>{errors.acceptTerms.message}</span>}
<button type="submit">Submit</button>
</form>
);
}
See what happened? The resolver: zodResolver(formSchema) option tells RHF to use Zod to validate the form data against your schema. The errors from Zod are automatically mapped into RHF’s errors object, complete with your custom messages. The generic on useForm<FormValues> gives you full type completion for register, errors, and the submitted data.
Handling Advanced Schemas and Transformation
Here’s where we move from “neat” to “indispensable.” Let’s say you have a field for a budget. Users type in a string, but you need a number. With plain RHF, you’re stuck using valueAsNumber and praying. With Zod, you define the transformation right in the schema, and the data that comes out the other side is already a number.
const advancedSchema = z.object({
budget: z.string()
.transform((val) => parseFloat(val)) // Transform from string to number
.refine((val) => !isNaN(val) && val >= 0, { // Then validate it's a positive number
message: "Please enter a valid number for your budget. We don't accept vibes.",
}),
startDate: z.string().transform(str => new Date(str)), // Transform to a Date object
});
type AdvancedValues = z.infer<typeof advancedSchema>;
// AdvancedValues is now { budget: number; startDate: Date; }
// ...inside your component...
const { register, handleSubmit } = useForm<AdvancedValues>({
resolver: zodResolver(advancedSchema),
});
The critical thing to understand: this transformation happens during validation. When you get the data in onSubmit, budget is already a number and startDate is already a Date object. This keeps your submission handler clean and purely business-logic-focused.
The One “Gotcha”: Async Validation
This is the only part that requires a slight brain shift. Zod’s .refine method can handle asynchronous validation (like checking if an email already exists), but you have to tell the zodResolver about it. It’s a simple flag, but if you forget it, your async validation will silently fail and you’ll spend 20 minutes questioning your life choices.
const asyncSchema = z.object({
email: z.string().email()
.refine(async (email) => {
// Simulate an API call
const isAvailable = await checkEmailAvailability(email);
return isAvailable;
}, "This email is already taken. Did you forget you signed up?"),
});
// ...in useForm...
const form = useForm({
resolver: zodResolver(asyncSchema),
mode: 'onChange', // Often you want to validate on change for async
});
// The crucial part: You MUST use form.handleSubmit in your JSX
// This ensures the async validation is properly awaited.
<form onSubmit={form.handleSubmit(onSubmit)}>
Notice the mode: 'onChange'? This is a good practice for async validation on fields like email, so the user gets feedback as they type instead of after they submit. Just be kind and debounce the actual API call in your checkEmailAvailability function to avoid melting your backend.
Integrating Zod with RHF isn’t just about validation; it’s about creating a single source of truth for your form’s data shape. You define it once in Zod, and you get types, runtime validation, and even data transformation for free. It’s the closest thing to a free lunch we get in this business.