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.

6 min read

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 :

  1. charger les données côté serveur ;
  2. traiter les mutations en POST, PUT, DELETE ;
  3. 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.

app/routes/auth.login.tsx
1
import { Form } from "react-router"
2
3
export default function Login() {
4
return (
5
<Form method="POST" className="space-y-4">
6
<input name="email" type="email" placeholder="Email" className="input" />
7
<input
8
name="password"
9
type="password"
10
placeholder="Mot de passe"
11
className="input"
12
/>
13
<button type="submit" className="btn-primary">Se connecter</button>
14
</Form>
15
)
16
}

Ce qu’il fait pour toi

  1. Bloque la navigation native du navigateur.
  2. Sérialise le FormData puis appelle action() de la route.
  3. Si l’action retourne un objet, il est accessible via useActionData().
  4. Termine en rappelant le loader pour 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
utils/validation.ts
1
import { z } from "zod"
2
3
export const loginSchema = z.object({
4
email: z.string().email("Adresse e-mail invalide"),
5
password: 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 ...)
app/routes/auth.login.tsx {9-15}
1
import { data } from "react-router"
2
import { loginSchema } from "~/utils/validation"
3
4
export async function action({ request }) {
5
const form = await request.formData()
6
const result = loginSchema.safeParse(Object.fromEntries(form))
7
8
if (!result.success) {
9
return data({ errors: result.error.flatten().fieldErrors }, { status: 400 })
10
}
11
12
// TODO: vérifier l’utilisateur en base
13
return 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.

app/routes/auth.login.tsx
1
import { useActionData } from "react-router"
2
import { useForm } from "@conform-to/react"
3
import { loginSchema } from "~/utils/validation"
4
import { parseWithZod, getZodConstraint } from "conform-to/zod"
5
6
export default function Login() {
7
const actionData = useActionData<typeof action>()
8
const [form, fields] = useForm({
9
lastResult: actionData, // ^? synchronise les erreurs serveur
10
onValidate({ formData }) {
11
return parseWithZod(formData,
12
({ schema: loginSchema })
13
)}, // ^? valide en temps réel
14
constraint: getZodConstraint(loginSchema),
15
16
})
17
18
return (
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é.

app/components/Field.tsx
1
export function Field({ label, errors, ...props }) {
2
return (
3
<div className="flex flex-col gap-1">
4
<label htmlFor={props.id}>{label}</label>
5
<input
6
{...props}
7
className="input"
8
aria-invalid={Boolean(errors?.length)}
9
aria-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 :

1
flowchart LR
2
A[Première requête] -->|GET| L1(loader)
3
L1 --> V1[Vue affichée]
4
V1 -->|POST<form>| A1(action)
5
A1 --> L2(loader)
6
L2 --> V2[Vue rafraîchie]
  • GET initial : loader hydrate la page avec les données serveur.
  • POST : action traite la mutation (validation, BDD, etc.).
  • Redirect ou JSON : React Router relance automatiquement le loader.
  • L’utilisateur voit immédiatement la mise à jour, sans useState ni 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.

app/routes/books._index.tsx
1
export async function loader({ request }) {
2
const url = new URL(request.url)
3
const parsed = filterSchema.safeParse(Object.fromEntries(url.searchParams))
4
if (!parsed.success) {
5
return json({ books: [], errors: parsed.error.flatten().fieldErrors })
6
}
7
return json({
8
books: await searchBooks(parsed.data),
9
errors: 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 Field factorise label, input, accessibilité et erreurs.
  • Cycle automatique loader ⇒ action ⇒ loader : l’UI reste toujours sync avec la base.

Exercices rapides

  1. 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 💪”.
  2. Switch GET/POST

    • Transforme le formulaire de recherche /users en method="GET".
    • Les paramètres doivent rester dans l’URL après un refresh.
  3. 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.

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 !