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.
avec React Router 7
Posez vos questions 24/7 à notre IA experte en React Router 7
Validez vos acquis avec des quiz personnalisés et un feedback instantané
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 :
required
, minLength
, …).Dans cette leçon on transforme le formulaire « Ajouter un utilisateur » pour qu’il bénéficie de ces super-pouvoirs.
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.
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.
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éduit required
, minLength
, etc.onValidate
: exécute le même parse avant d’envoyer au serveur.lastResult
: ré-injecte les erreurs renvoyées par l’action.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.
Côté dev, tu bénéficies :
fields.firstName.errors
) ;userActionSchema
) ;useState
ni useEffect
pour
gérer les erreurs).getZodConstraint()
génère les attributs HTML à partir du schéma.parseWithZod()
produit un objet submission
homogène (succès/échec).useForm()
fusionne erreurs client et serveur pour un feedback unifié.Field
centralise label, input, accessibilité et messageQuelle est la principale différence entre les composants client et serveur dans React ?
Quelle technique est recommandée pour éviter les rendus inutiles dans React ?
Quel hook permet de gérer les effets de bord dans un composant React ?
Comment implémenter la gestion des erreurs pour les requêtes API dans React ?
Quelle est la meilleure pratique pour déployer une application React en production ?