Les formulaires sont la partie la plus ingrate de toute application web. La logique de validation se retrouve éparpillée dans les handlers, les messages d'erreur se désynchronisent des contraintes réelles, et les types TypeScript finissent dupliqués entre le schéma et le composant. Zod et React Hook Form résolvent ce problème ensemble : Zod définit la forme et les règles de vos données dans un schéma unique, React Hook Form gère le rendu et la performance, et @hookform/resolvers fait le lien pour que vos types, votre validation et votre UI restent parfaitement alignés.
Zod : des schémas qui servent aussi de types
Zod est une librairie de validation TypeScript-first. On définit un schéma une seule fois et on obtient à la fois la validation runtime et un type statique, sans aucune duplication.
import { z } from 'zod';
export const contactSchema = z.object({
name: z.string().min(1, 'Le nom est requis'),
email: z.string().email('Adresse email invalide'),
message: z.string().min(10, 'Le message doit contenir au moins 10 caractères'),
budget: z.enum(['small', 'medium', 'large']),
});
export type ContactFormData = z.infer<typeof contactSchema>;ContactFormData est dérivé directement du schéma. Si vous ajoutez un champ au schéma, le type se met à jour automatiquement. Si vous changez une contrainte, la validation runtime suit. Une seule source de vérité pour tout.
React Hook Form : la performance par défaut
React Hook Form gère l'état des champs via des inputs non contrôlés et des refs, ce qui évite de re-rendre tout l'arbre à chaque frappe clavier. Pour les formulaires avec beaucoup de champs ou des layouts complexes, ça fait une vraie différence par rapport aux approches contrôlées.
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { contactSchema, type ContactFormData } from '@/schemas/contact';
export function ContactForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ContactFormData>({
resolver: zodResolver(contactSchema),
});
const onSubmit = (data: ContactFormData) => {
// data est entièrement typé et validé ici
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name')} />
{errors.name && <span>{errors.name.message}</span>}
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<textarea {...register('message')} />
{errors.message && <span>{errors.message.message}</span>}
<select {...register('budget')}>
<option value="small">Petit</option>
<option value="medium">Moyen</option>
<option value="large">Grand</option>
</select>
<button type="submit">Envoyer</button>
</form>
);
}Le zodResolver connecte la validation de Zod au système d'erreurs de React Hook Form. Chaque message d'erreur défini dans le schéma remonte directement dans formState.errors, avec un typage complet sur les noms de champs.
Composer des schémas complexes
Les formulaires du monde réel restent rarement plats. L'API de composition de Zod gère les objets imbriqués, les champs conditionnels et la validation inter-champs sans casser la chaîne d'inférence de types.
Objets imbriqués et tableaux
const milestoneSchema = z.object({
title: z.string().min(1),
dueDate: z.string().date(),
});
const projectSchema = z.object({
name: z.string().min(1),
description: z.string().optional(),
milestones: z.array(milestoneSchema).min(1, 'Ajoutez au moins une étape'),
});Validation inter-champs avec refine
Parfois un champ dépend d'un autre. Les méthodes refine et superRefine de Zod permettent d'exprimer ces contraintes au niveau du schéma plutôt que de les enfouir dans les handlers de soumission.
const dateRangeSchema = z.object({
startDate: z.string().date(),
endDate: z.string().date(),
}).refine(
(data) => new Date(data.endDate) > new Date(data.startDate),
{ message: 'La date de fin doit être après la date de début', path: ['endDate'] }
);L'option path indique à React Hook Form exactement quel champ doit afficher l'erreur. Sans elle, l'erreur serait attachée à la racine du formulaire, invisible pour l'utilisateur.
Champs dynamiques avec useFieldArray
Les formulaires avec des sections répétables, comme l'ajout de plusieurs membres d'équipe ou de lignes de détail, combinent useFieldArray de React Hook Form avec la validation de tableaux de Zod.
import { useForm, useFieldArray } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const teamSchema = z.object({
members: z.array(z.object({
name: z.string().min(1, 'Le nom est requis'),
role: z.string().min(1, 'Le rôle est requis'),
})).min(1, 'Ajoutez au moins un membre'),
});
type TeamFormData = z.infer<typeof teamSchema>;
export function TeamForm() {
const { register, control, handleSubmit, formState: { errors } } = useForm<TeamFormData>({
resolver: zodResolver(teamSchema),
defaultValues: { members: [{ name: '', role: '' }] },
});
const { fields, append, remove } = useFieldArray({ control, name: 'members' });
return (
<form onSubmit={handleSubmit(console.log)}>
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`members.${index}.name`)} />
{errors.members?.[index]?.name && (
<span>{errors.members[index]?.name?.message}</span>
)}
<input {...register(`members.${index}.role`)} />
<button type="button" onClick={() => remove(index)}>Supprimer</button>
</div>
))}
<button type="button" onClick={() => append({ name: '', role: '' })}>
Ajouter un membre
</button>
<button type="submit">Soumettre</button>
</form>
);
}Chaque champ du tableau dynamique est validé individuellement. Ajoutez un membre avec un nom vide, et seule cette ligne spécifique affiche une erreur. Les types restent corrects même pour les accès indexés profondément imbriqués comme members.${index}.name.
Réutilisation côté serveur
L'un des plus grands atouts de Zod est que les schémas fonctionnent de manière identique sur le serveur. Le même contactSchema utilisé dans votre composant peut valider la requête entrante dans votre route API ou votre Server Action.
import { contactSchema } from '@/schemas/contact';
export async function POST(request: Request) {
const body = await request.json();
const result = contactSchema.safeParse(body);
if (!result.success) {
return Response.json(
{ errors: result.error.flatten().fieldErrors },
{ status: 400 }
);
}
// result.data est entièrement typé comme ContactFormData
await sendEmail(result.data);
return Response.json({ success: true });
}safeParse retourne une union discriminée : soit { success: true, data } soit { success: false, error }. Pas besoin de try/catch, et TypeScript affine le type automatiquement après la vérification. Le client et le serveur valident selon exactement les mêmes règles, à partir du même code.
Conclusion
Zod et React Hook Form se complètent précisément là où ça compte. Zod possède la forme des données et les règles de validation, React Hook Form possède le rendu et l'UX. Le résultat, ce sont des formulaires où les types, les contraintes et les messages d'erreur sont définis une seule fois et restent cohérents du navigateur jusqu'à l'API. Une fois qu'on a construit des formulaires de cette manière, revenir à la validation manuelle donne l'impression de coder avec une main attachée dans le dos.