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.
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 :
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 à :
<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}
FormData
puis appelle action()
de la route.action
retourne un objet, il est accessible via useActionData()
.loader
pour afficher la donnée fraîche.Nous allons utiliser Zod pour valider nos données. Le principe est de valider nos formulaires côté client :
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})
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 :
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}
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}
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.
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]
loader
hydrate la page avec les données serveur.action
traite la mutation (validation, BDD, etc.).loader
.useState
ni Refetch manuel.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.
Grâce à Conform :
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.
<Form>
+ loader/action
= full-stack forms sans boilerplate.Field
factorise label, input, accessibilité et erreurs.loader ⇒ action ⇒ loader
: l’UI reste toujours sync avec la base.Ajoute la règle “mot de passe fort”
Switch GET/POST
/users
en method="GET"
.Indicateur de Submit
navigation.state === "submitting"
.Besoin d’inspiration ? Explore le code source du dossier
forms/3-0-introduction
dans ton repo local. Tu verras tous les patterns expliqués ci-dessus… appliqués en condition réelle !