patterntypescriptreactTip
React Hook Form — schema validation with Zod via zodResolver
Viewed 0 times
zodResolverzodreact hook formschema validationz.inferrefinecoerce
Problem
React Hook Form's built-in validation rules (required, minLength, pattern) are adequate for simple forms but become unwieldy for complex schemas. Zod provides a declarative, composable schema that validates the entire form as a unit and produces typed data.
Solution
Pass zodResolver to useForm and derive the TypeScript type from the schema:
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const registerSchema = z.object({
email: z.string().email('Invalid email address'),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Must contain an uppercase letter'),
confirmPassword: z.string(),
age: z.number({ coerce: true }).int().min(18, 'Must be 18 or older'),
}).refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
type RegisterForm = z.infer<typeof registerSchema>;
export function RegisterForm() {
const { register, handleSubmit, formState: { errors } } =
useForm<RegisterForm>({
resolver: zodResolver(registerSchema),
});
const onSubmit = (data: RegisterForm) => {
// data is fully typed and validated
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{errors.email && <p>{errors.email.message}</p>}
<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">Register</button>
</form>
);
}
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const registerSchema = z.object({
email: z.string().email('Invalid email address'),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Must contain an uppercase letter'),
confirmPassword: z.string(),
age: z.number({ coerce: true }).int().min(18, 'Must be 18 or older'),
}).refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
type RegisterForm = z.infer<typeof registerSchema>;
export function RegisterForm() {
const { register, handleSubmit, formState: { errors } } =
useForm<RegisterForm>({
resolver: zodResolver(registerSchema),
});
const onSubmit = (data: RegisterForm) => {
// data is fully typed and validated
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{errors.email && <p>{errors.email.message}</p>}
<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">Register</button>
</form>
);
}
Why
zodResolver runs the full Zod schema on submit (and optionally on change). z.infer derives the TypeScript interface directly from the schema, keeping the type definition and the validation rules in sync automatically.
Gotchas
- Install @hookform/resolvers separately — it is not included in react-hook-form
- z.number() rejects string inputs from HTML inputs by default — use z.coerce.number() or z.number({ coerce: true }) for numeric text inputs
- Cross-field validation (password match) must use .refine() at the object level with path pointing to the error field
- Errors from .refine() appear on the path specified — accessing them is the same as field-level errors
Revisions (0)
No revisions yet.