Validation avec Zod et Conform
Zod et Conform permettent de valider un formulaire une seule fois, côté client et serveur, avec moins de code, des erreurs précises et des champs automatiquement configurés.
Pourquoi abandonner la validation “maison” ?
Taper à la main un if (!firstName) { errors.push(...) } fonctionne… jusqu’au jour où tu oublies un champ, ou qu’un collègue renomme firstName en givenName.
Résultat : une logique disséminée, fragile, impossible à garder en phase entre le
client et le serveur.
La bonne nouvelle : le duo Zod + Conform te permet de :
- Écrire le schéma de tes données une seule fois.
- Obtenir la validation côté client et côté serveur sans copier-coller.
- Générer automatiquement les attributs HTML (
required,minLength, …). - Afficher les erreurs et donner le focus au 1ᵉʳ champ fautif, sans code supplémentaire.
Dans cette leçon on transforme le formulaire « Ajouter un utilisateur » pour qu’il bénéficie de ces super-pouvoirs.
1. Définir le schéma Zod partagé
Crée d’abord un fichier dédié aux règles métier :
1import { z } from "zod";23export const userCreateSchema = z.object({4action: z.literal("create-user"),5firstName: z6.string({ required_error: "Le prénom est requis" })7.min(2, "Longueur minimum : 2 caractères"),8lastName: z9.string({ required_error: "Le nom est requis" })10.min(2, "Longueur minimum : 2 caractères"),11});1213export const userUpdateSchema = z.object({14action: z.literal("update-user"),15firstName: z.string().min(2),16lastName: z.string().min(2),17});1819/** Schéma discriminé – create OU update */20export const userActionSchema = z.discriminatedUnion("action", [21userCreateSchema,22userUpdateSchema,23]);
Ce fichier est le single source of truth : toute la validation part d’ici.
2. Sécuriser l’action côté serveur
Dans la route dynamique users.$userSlug.tsx :
1import { parseWithZod } from "@conform-to/zod";2import { userActionSchema } from "~/schemas/user";34export async function action({ request, params }) {5const formData = await request.formData();67// 1. On parse de manière « safe » (pas d’exception JS)8const submission = await parseWithZod(formData, {9schema: userActionSchema.superRefine(async (data, ctx) => {10if (data.action === "create-user") {11// règle business : « slug » unique12const slug = createSlug({ firstName: data.firstName });13if (await getUserBySlug({ slug })) {14ctx.addIssue({15path: ["firstName"],16code: "custom",17message: "Un utilisateur porte déjà ce prénom",18});19}20}21}),22});2324// 2. Si erreur → on renvoie un JSON structuré pour Conform25if (submission.status !== "success") {26return data({ result: submission.reply() }, { status: 400 });27}2829// 3. Sinon on exécute la mutation30/* … addUser / updateUser … */31}
Avantage : la même structure submission.reply() sera réutilisée côté
client – zéro sérialisation manuelle.
3. Connecter le formulaire React
3.1 Initialiser Conform
1import { getFormProps, getInputProps, useForm } from "@conform-to/react";2import { getZodConstraint } from "@conform-to/zod";3import { useActionData, useLoaderData, Form } from "react-router";4import { userActionSchema } from "~/schemas/user";56function UserForm() {7const { user, isNew } = useLoaderData<typeof loader>();8const actionData = useActionData<typeof action>();910const [form, fields] = useForm({11id: "user-form",12lastResult: actionData?.result, // 🡄 erreurs serveur13constraint: getZodConstraint(userActionSchema), // 🡄 attrs HTML14onValidate({ formData }) {15/* Validation instantanée côté client */16return parseWithZod(formData, { schema: userActionSchema });17},18defaultValue: {19firstName: user?.firstName,20lastName: user?.lastName,21action: isNew ? "create-user" : "update-user",22},23});
constraint: déduitrequired,minLength, etc.onValidate: exécute le même parse avant d’envoyer au serveur.lastResult: ré-injecte les erreurs renvoyées par l’action.
3.2 Brancher les champs avec getInputProps
1return (2<Form method="POST" {...getFormProps(form)}3className="space-y-4 bg-white p-4 rounded shadow">4{/* champ action caché */}5<input {...getInputProps(fields.action, { type: "hidden" })} />67<Field /* composant réutilisable */8labelProps={{ children: "Prénom" }}9inputProps={getInputProps(fields.firstName, { type: "text" })}10errors={fields.firstName.errors}11/>1213<Field14labelProps={{ children: "Nom" }}15inputProps={getInputProps(fields.lastName, { type: "text" })}16errors={fields.lastName.errors}17/>1819<button20type="submit"21disabled={form.submission?.pending}22className={`btn ${isNew ? "bg-emerald-600" : "bg-blue-600"}`}23>24{form.submission?.pending25? "En cours…"26: isNew27? "Ajouter"28: "Modifier"}29</button>30</Form>31);32}
Magie : Conform ajoute pour toi required + minLength="2" sur
l’<input> et focus automatiquement le premier champ invalide.
4. Démonstration : UX et DX au top !
- Tu cliques sur Ajouter sans rien saisir.
- Les erreurs apparaissent instantanément, le focus est sur Prénom.
- Tu tapes “A”, le message “2 caractères mini” reste ; tape “Ax” → hop, l’erreur disparaît.
- Tu valides : si le prénom existe déjà, la validation serveur renvoie l’erreur et Conform l’affiche au même endroit, sans reload brutal.
Côté dev, tu bénéficies :
- d’un autocomplétion TypeScript (
fields.firstName.errors) ; - d’une seule source de validation (
userActionSchema) ; - d’un code React ultra-léger (plus de
useStateniuseEffectpour gérer les erreurs).
5. Points clés
- Zod décrit le quoi ; Conform gère le comment.
getZodConstraint()génère les attributs HTML à partir du schéma.parseWithZod()produit un objetsubmissionhomogène (succès/échec).useForm()fusionne erreurs client et serveur pour un feedback unifié.- Un composant
Fieldcentralise label, input, accessibilité et message
Comprendre les concepts fondamentaux
Quelle est la principale différence entre les composants client et serveur dans React ?
Optimisation des performances
Quelle technique est recommandée pour éviter les rendus inutiles dans React ?
Architecture des données
Quel hook permet de gérer les effets de bord dans un composant React ?