Maîtrise les formulaires dans React Router 7
Les formulaires permettent à nos utilisateurs d'intéragir avec notre application. Mais un formulaire, c'est bien plus qu'un élément html avec un input. Découvre comment mettre en place des formulaires full typesafe, proposant une expérience utilisateur exceptionnelle et une expérience développeur hors-norme.
Pourquoi un module dédié aux formulaires ?
Afficher des données c’est bien. Mais la plupart des applications permettent à l’utilisateur d'intéragir avec : En créant ou en modifiant les données.
Avec React Router 7, tu disposes déjà du composant <Form> et du duo loader / action pour :
- charger les données côté serveur ;
- traiter les mutations en POST, PUT, DELETE ;
- re-hydrater l’UI automatiquement.
Mais, soyons francs : un simple <Form> ne suffit pas pour offrir la même DX/UX qu’un Google Docs ou qu’un Stripe Checkout.
Dans cette leçon tu vas apprendre à :
- valider ton formulaire de manière type-safe avec Zod
- utiliser le même schéma côté serveur et côté client grâce à Conform
- construire un composant champ réutilisable qui gère label, input et messages d’erreur en un clin d’œil
Le composant Form de React Router
<Form> remplace ton <form> HTML classique : il gère la navigation, appelle l’action, puis relance le loader pour rafraîchir la page courante – tout ça sans useEffect, sans fetch externe et sans état local parasite.
1import { Form } from "react-router"23export default function Login() {4return (5<Form method="POST" className="space-y-4">6<input name="email" type="email" placeholder="Email" className="input" />7<input8name="password"9type="password"10placeholder="Mot de passe"11className="input"12/>13<button type="submit" className="btn-primary">Se connecter</button>14</Form>15)16}
Ce qu’il fait pour toi
- Bloque la navigation native du navigateur.
- Sérialise le
FormDatapuis appelleaction()de la route. - Si l’
actionretourne un objet, il est accessible viauseActionData(). - Termine en rappelant le
loaderpour afficher la donnée fraîche.
Validation 100 % type-safe avec Zod
Nous allons utiliser Zod pour valider nos données. Le principe est de valider nos formulaires côté client :
- Quel est le type de donnée ? string, number, booléen, email ...
- Est-ce que la valeur est obligatoire ? Propriété HTML required
- Quelle est la longueure imposée par notre élément ? Longueur minimum, maximum avec min et max
1import { z } from "zod"23export const loginSchema = z.object({4email: z.string().email("Adresse e-mail invalide"),5password: z.string().min(8, "8 caractères minimum"),6})
Côté serveur : sécuriser l’action
Nous réutilisons le même schéma Zod côté serveur pour valider la donnée. Elle sera accessible, et entièrement typée. Côté serveur, nous pouvons également rajouter des étapes de validations supplémentaires :
- Est-ce que l'utilisateur existe ?
- N'importe quel traitement asynchrone (base de donnée, API externe ...)
1import { data } from "react-router"2import { loginSchema } from "~/utils/validation"34export async function action({ request }) {5const form = await request.formData()6const result = loginSchema.safeParse(Object.fromEntries(form))78if (!result.success) {9return data({ errors: result.error.flatten().fieldErrors }, { status: 400 })10}1112// TODO: vérifier l’utilisateur en base13return data({ ok: true })14}
Côté client : feedback instantané
Si le formulaire n'a pas été validé par le serveur, ce dernier renvoit un message d'erreur que le client pourra ensuite afficher à côté de notre formulaire.
1import { useActionData } from "react-router"2import { useForm } from "@conform-to/react"3import { loginSchema } from "~/utils/validation"4import { parseWithZod, getZodConstraint } from "conform-to/zod"56export default function Login() {7const actionData = useActionData<typeof action>()8const [form, fields] = useForm({9lastResult: actionData, // ^? synchronise les erreurs serveur10onValidate({ formData }) {11return parseWithZod(formData,12({ schema: loginSchema })13)}, // ^? valide en temps réel14constraint: getZodConstraint(loginSchema),1516})1718return (19<Form method="POST" {...form.props} className="space-y-4">20<Field {...fields.email} label="Email" />21<Field {...fields.password} label="Mot de passe" type="password" />22<button className="btn-primary">Se connecter</button>23</Form>24)25}
Créer un champ réutilisable
Dans le repo d’exemple tu trouveras déjà Field.tsx.
Il encapsule label + input + liste d’erreurs et t’assure une bonne accessibilité.
1export function Field({ label, errors, ...props }) {2return (3<div className="flex flex-col gap-1">4<label htmlFor={props.id}>{label}</label>5<input6{...props}7className="input"8aria-invalid={Boolean(errors?.length)}9aria-describedby={errors?.length ? `${props.id}-error` : undefined}10/>11{errors?.length && (12<ul id={`${props.id}-error`} className="text-red-500 text-sm">13{errors.map((e) => <li key={e}>{e}</li>)}14</ul>15)}16</div>17)18}
Objectif : Simple à maintenir et à réutiliser. Pas besoin de s'embêter à ajouter les <label> ni les aria-… toi-même. Tu te concentres sur la logique métier.
Cycle complet loader ⇒ action ⇒ loader
Petit rappel graphique :
1flowchart LR2A[Première requête] -->|GET| L1(loader)3L1 --> V1[Vue affichée]4V1 -->|POST<form>| A1(action)5A1 --> L2(loader)6L2 --> V2[Vue rafraîchie]
- GET initial :
loaderhydrate la page avec les données serveur. - POST :
actiontraite la mutation (validation, BDD, etc.). - Redirect ou JSON : React Router relance automatiquement le
loader. - L’utilisateur voit immédiatement la mise à jour, sans
useStateni Refetch manuel.
Cas GET : formulaires de filtres partageables
Les filtres de la démo GoodCollect utilisent la méthode GET : la valeur se retrouve dans l’URL (?q=react&tag=remix).
Même schéma Conform/Zod, mais method="GET" et validation côté loader.
1export async function loader({ request }) {2const url = new URL(request.url)3const parsed = filterSchema.safeParse(Object.fromEntries(url.searchParams))4if (!parsed.success) {5return json({ books: [], errors: parsed.error.flatten().fieldErrors })6}7return json({8books: await searchBooks(parsed.data),9errors: null,10})11}
Bonus : l’URL est partageable d’un simple copier-coller.
UX haute qualité : focus automatique et messages compréhensibles
Grâce à Conform :
- le premier champ en erreur reçoit automatiquement le focus ;
- les messages d’erreur s’affichent dès la frappe, pas après un reload ;
- le bouton est désactivé quand l’envoi est en cours (
navigation.state === "submitting").
Essaye dans le playground, tu verras : impossible de soumettre “toto” dans l’input e-mail – l’utilisateur comprend instantanément ce qui cloche.
Points clés à retenir
<Form>+loader/action= full-stack forms sans boilerplate.- Zod fournit un schéma unique, garanti type-safe, réutilisable partout.
- Conform branche ce schéma côté client pour la validation instantanée et gère focus, disabled, etc.
- Un composant
Fieldfactorise label, input, accessibilité et erreurs. - Cycle automatique
loader ⇒ action ⇒ loader: l’UI reste toujours sync avec la base.
Exercices rapides
-
Ajoute la règle “mot de passe fort”
- Mots de passe ≥ 12 caractères, 1 majuscule, 1 chiffre.
- Retourne un message personnalisé “Ton mot de passe est trop faible 💪”.
-
Switch GET/POST
- Transforme le formulaire de recherche
/usersenmethod="GET". - Les paramètres doivent rester dans l’URL après un refresh.
- Transforme le formulaire de recherche
-
Indicateur de Submit
- Affiche un spinner animé sur le bouton quand
navigation.state === "submitting". - Le spinner disparaît dès que la réponse arrive.
- Affiche un spinner animé sur le bouton quand
Besoin d’inspiration ? Explore le code source du dossier
forms/3-0-introductiondans ton repo local. Tu verras tous les patterns expliqués ci-dessus… appliqués en condition réelle !