Comment valider un formulaire côté serveur
Apprends à valider les formulaires côté serveur avec Zod + Conform : unicité, logique métier, erreurs asynchrones et UX préservée grâce à submission.reply() et superRefine.
Pourquoi faire une validation serveur ?
La validation côté client (Conform + Zod) protège l’utilisateur des fautes de frappe, mais elle ne peut pas :
- vérifier l’unicité d’une donnée (email, slug, …) ;
- interroger une base ou une API tierce ;
- garantir l’intégrité en cas d’appel API direct (Postman, cURL…).
Bref : la dernière ligne de défense vit sur le serveur. Dans cette leçon tu vas apprendre à :
- ré-utiliser ton schéma Zod dans l’
action(); - renvoyer un objet
SubmissionResultinterprétable par Conform ; - gérer plusieurs intentions (création / mise à jour) avec un discriminated union ;
- afficher instantanément les messages sans perdre la saisie.
1. Construis un schéma partagé
1import { z } from "zod"23export const UserCreateSchema = z.object({4action: z.literal("create-user"),5firstName: z.string().min(2, "2 caractères minimum"),6lastName: z.string().min(2, "2 caractères minimum"),7})89export const UserUpdateSchema = z.object({10action: z.literal("update-user"),11firstName: z.string().min(2),12lastName: z.string().min(2),13})1415export const UserActionsSchema = z.discriminatedUnion(16"action",17[UserCreateSchema, UserUpdateSchema],18)
Discriminated union 👉 un seul schéma, plusieurs cas, distingués par la clef action.
Conform l’utilisera côté client et nous côté serveur : zéro duplication !
2. Parse le FormData avec Zod côté serveur
1import { parseWithZod } from "@conform-to/zod";2import { data } from "react-router";34export async function action({ request, params }) {5const formData = await request.formData()67const submission = await parseWithZod(formData, {8async: true, // autorise le code asynchrone9schema: UserActionsSchema.superRefine(async (values, ctx) => {10if (values.action === "create-user") {11const slug = createSlug({ firstName: values.firstName })12const exists = await getUserBySlug({ slug })13if (exists) {14ctx.addIssue({15path: ["firstName"],16code: "custom",17message: "Cet utilisateur existe déjà.",18})19}20}21}),22})2324if (submission.status !== "success") {25// ^ renvoie un format compris par Conform26return data({ result: submission.reply() }, { status: 400 })27}2829/* …traitement en BDD, puis redirect … */30}
À retenir
parseWithZod(formData)convertitFormData→ objet et applique le schéma.superRefinete donne un hook async pour parler à ta base.submission.reply()sérialise les erreurs pour Conform.
3. Exploite le résultat côté client
1import { useActionData, useLoaderData } from "react-router"2import { useForm, getFormProps, getInputProps } from "@conform-to/react"34export default function UserForm() {5const { isNew, user } = useLoaderData<typeof loader>()6const actionData = useActionData<typeof action>()78const [form, fields] = useForm({9id: "user-form",10lastResult: actionData?.result, // 🔗 lien serveur → client11defaultValue: {12firstName: user?.firstName,13lastName: user?.lastName,14action: isNew ? "create-user" : "update-user",15},16})1718return (19<Form method="POST" {...getFormProps(form)} className="space-y-4">20<input { ...getInputProps(fields.action, { type: "hidden" }) } />21{/* firstname */}22<Field23labelProps={{ children: "Prénom" }}24inputProps={ getInputProps(fields.firstName, { type: "text" }) }25errors={fields.firstName.errors}26/>27{/* lastname */}28<Field29labelProps={{ children: "Nom" }}30inputProps={ getInputProps(fields.lastName, { type: "text" }) }31errors={fields.lastName.errors}32/>33<button className="btn-primary">34{isNew ? "Ajouter" : "Modifier"}35</button>36</Form>37)38}
Conform mélange :
- erreurs client (validations synchrones) ;
- erreurs serveur (format
Submission.reply()).
Résultat : l’utilisateur reste sur place, ses champs ne sont pas réinitialisés, et le premier input en erreur reprend automatiquement le focus. ✨
4. Router la logique avec switch
Les valeurs validées vivent dans submission.value.
Grâce au discriminant, TypeScript sait exactement de quel cas il s’agit !
1switch (submission.value.action) {2case "create-user": {3const { slug } = await addUser(submission.value)4return redirect(href("/users/:userSlug", { userSlug: slug }))5}6case "update-user": {7const { slug } = await updateUser({8slug: params.userSlug,9userData: submission.value,10})11return redirect(href("/users/:userSlug", { userSlug: slug }))12}13}
5. Quand le composant doit-il se re-monter ?
Conform stocke les default values à l’instanciation du hook useForm.
Si tu changes de slug sans recréer le composant tu conserves l’ancien état.
La parade : clé dynamique sur le composant enfant.
1export default function User() {2const { userSlug } = useParams()3return <UserForm key={userSlug} /> // force un remount4}
Points clés à mémoriser
- Validation serveur obligatoire pour tout ce qui touche à la base ou à l’unicité.
parseWithZod+superRefine= validation type-safe et async en une ligne.- Renvoyez toujours
submission.reply()pour que Conform aligne UI et back-end. - Le schéma Zod devient la source de vérité unique : typed API, validation, autocomplétion VS Code.
- Un composant clef dynamique (
key={userSlug}) garantit la synchronisation des valeurs par défaut lors d’une navigation interne.
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 ?