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.

4 min read

Continuez votre apprentissage

Accédez aux 9 modules et 76 leçons de cette formation.

599

Accès à vie · Garantie 30 jours

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 :

  1. Écrire le schéma de tes données une seule fois.
  2. Obtenir la validation côté client et côté serveur sans copier-coller.
  3. Générer automatiquement les attributs HTML (required, minLength, …).
  4. 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 :

app/schemas/user.ts
1
import { z } from "zod";
2
3
export const userCreateSchema = z.object({
4
action: z.literal("create-user"),
5
firstName: z
6
.string({ required_error: "Le prénom est requis" })
7
.min(2, "Longueur minimum : 2 caractères"),
8
lastName: z
9
.string({ required_error: "Le nom est requis" })
10
.min(2, "Longueur minimum : 2 caractères"),
11
});
12
13
export const userUpdateSchema = z.object({
14
action: z.literal("update-user"),
15
firstName: z.string().min(2),
16
lastName: z.string().min(2),
17
});
18
19
/** Schéma discriminé – create OU update */
20
export const userActionSchema = z.discriminatedUnion("action", [
21
userCreateSchema,
22
userUpdateSchema,
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 :

app/routes/users+/$userSlug.tsx {10-35}
1
import { parseWithZod } from "@conform-to/zod";
2
import { userActionSchema } from "~/schemas/user";
3
4
export async function action({ request, params }) {
5
const formData = await request.formData();
6
7
// 1. On parse de manière « safe » (pas d’exception JS)
8
const submission = await parseWithZod(formData, {
9
schema: userActionSchema.superRefine(async (data, ctx) => {
10
if (data.action === "create-user") {
11
// règle business : « slug » unique
12
const slug = createSlug({ firstName: data.firstName });
13
if (await getUserBySlug({ slug })) {
14
ctx.addIssue({
15
path: ["firstName"],
16
code: "custom",
17
message: "Un utilisateur porte déjà ce prénom",
18
});
19
}
20
}
21
}),
22
});
23
24
// 2. Si erreur → on renvoie un JSON structuré pour Conform
25
if (submission.status !== "success") {
26
return data({ result: submission.reply() }, { status: 400 });
27
}
28
29
// 3. Sinon on exécute la mutation
30
/* … 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

app/routes/users+/$userSlug.tsx {70-97}
1
import { getFormProps, getInputProps, useForm } from "@conform-to/react";
2
import { getZodConstraint } from "@conform-to/zod";
3
import { useActionData, useLoaderData, Form } from "react-router";
4
import { userActionSchema } from "~/schemas/user";
5
6
function UserForm() {
7
const { user, isNew } = useLoaderData<typeof loader>();
8
const actionData = useActionData<typeof action>();
9
10
const [form, fields] = useForm({
11
id: "user-form",
12
lastResult: actionData?.result, // 🡄 erreurs serveur
13
constraint: getZodConstraint(userActionSchema), // 🡄 attrs HTML
14
onValidate({ formData }) {
15
/* Validation instantanée côté client */
16
return parseWithZod(formData, { schema: userActionSchema });
17
},
18
defaultValue: {
19
firstName: user?.firstName,
20
lastName: user?.lastName,
21
action: 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.

3.2 Brancher les champs avec getInputProps

app/routes/users+/$userSlug.tsx {99-128}
1
return (
2
<Form method="POST" {...getFormProps(form)}
3
className="space-y-4 bg-white p-4 rounded shadow">
4
{/* champ action caché */}
5
<input {...getInputProps(fields.action, { type: "hidden" })} />
6
7
<Field /* composant réutilisable */
8
labelProps={{ children: "Prénom" }}
9
inputProps={getInputProps(fields.firstName, { type: "text" })}
10
errors={fields.firstName.errors}
11
/>
12
13
<Field
14
labelProps={{ children: "Nom" }}
15
inputProps={getInputProps(fields.lastName, { type: "text" })}
16
errors={fields.lastName.errors}
17
/>
18
19
<button
20
type="submit"
21
disabled={form.submission?.pending}
22
className={`btn ${isNew ? "bg-emerald-600" : "bg-blue-600"}`}
23
>
24
{form.submission?.pending
25
? "En cours…"
26
: isNew
27
? "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 !

  1. Tu cliques sur Ajouter sans rien saisir.
  2. Les erreurs apparaissent instantanément, le focus est sur Prénom.
  3. Tu tapes “A”, le message “2 caractères mini” reste ; tape “Ax” → hop, l’erreur disparaît.
  4. 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 useState ni useEffect pour 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 objet submission homogène (succès/échec).
  • useForm() fusionne erreurs client et serveur pour un feedback unifié.
  • Un composant Field centralise label, input, accessibilité et message
Inclus
Quiz interactifTestez vos connaissances
Validez votre compréhension du module avec notre quiz interactif personnalisé.
1

Comprendre les concepts fondamentaux

Quelle est la principale différence entre les composants client et serveur dans React ?

Les composants client s'exécutent uniquement dans le navigateur
Les composants serveur peuvent utiliser useState
2

Optimisation des performances

Quelle technique est recommandée pour éviter les rendus inutiles dans React ?

Utiliser React.memo pour les composants fonctionnels
Ajouter plus d'états locaux
3

Architecture des données

Quel hook permet de gérer les effets de bord dans un composant React ?

useEffect
useState

Débloquez ce quiz et tous les autres contenus