Créer un composant de formulaire amélioré
Apprends à structurer des formulaires React Router 7 robustes : erreurs par champ, UX sans perte de saisie, accessibilité native, et composants réutilisables sans état local.
Du <Form> basique au champ haut-de-gamme
Le composant <Form> de React Router 7 nous offre déjà un cycle
loader ⇒ action ⇒ loader transparent ; pourtant l’expérience reste minimale :
• aucun retour d’erreur par champ,
• impossible de conserver la saisie si le serveur répond 400,
• pas d’indication de chargement.
Dans cette leçon tu vas apprendre à :
- structurer une réponse d’
actionriche (succès + erreurs), - consommer cette réponse côté client avec
useActionData, - créer un composant
Fieldréutilisable qui gère label, input, accessibilité et messages, - indiquer l’état submitting avec
useNavigation.
On reste sans dépendance externe. Zod & Conform arriveront dans la prochaine leçon pour automatiser toute la validation !
1. Définir un type de réponse uniforme
Premier objectif : faire parler l’action sans rediriger.
Créons un type UserActionResponse qui rassemble le statut, un slug de
redirection éventuel et un objet errors mappé sur chaque champ.
1type UserActionResponse = {2success: boolean3slug: string // redirection facultative4errors: {5firstName?: string[]6lastName?: string[]7}8}
Construire la réponse dans addUser
1export async function addUser({ firstName, lastName }: {2firstName: string3lastName: string4}): Promise<UserActionResponse> {56const res: UserActionResponse = {7success: true,8slug: '',9errors: { firstName: [], lastName: [] }10}1112// validation manuelle – côté serveur13if (!firstName) {14res.errors.firstName!.push('Le prénom est requis')15res.success = false16}17if (!lastName) {18res.errors.lastName!.push('Le nom est requis')19res.success = false20}2122if (!res.success) return res // on stoppe net2324/* …vérif unicité, insertion DB… */25res.slug = firstName.toLowerCase()26return res27}
2. Renvoyer proprement la réponse dans l’action
Dans la route dynamique users.$userSlug.tsx :
1import { data, redirect } from "react-router"23/* … */45export async function action({ request, params }) {6const fd = await request.formData()7const firstName = fd.get('firstName') as string8const lastName = fd.get('lastName') as string9const isNew = params.userSlug === 'new'1011if (isNew) {12const created = await addUser({ firstName, lastName })1314if (!created.success) {15// ↩︎ Renvoi JSON + statut 40016return data(created, { status: 400 })17}1819// ↪︎ Tout est ok → redirection20return redirect(21href('/users/:userSlug', { userSlug: created.slug })22)23}2425/* …mise à jour existant… */26}
data() fournit le bon header application/json sans que tu t’en soucies.
3. Consommer la réponse côté client
a/ Récupérer les erreurs
1import { useActionData } from "react-router"23export default function UserForm() {4const actionData = useActionData<typeof action>() // tipé gratis5/* … */6}
actionData vaut undefined si la requête précédente était un succès.
b/ Désactiver le bouton pendant l’envoi
1import { useNavigation } from "react-router"23const navigation = useNavigation()4const isSubmitting = navigation.state === 'submitting'
4. Construire un champ réutilisable
Nous voulons un composant unique pour :
• afficher le label,
• binder l’input,
• lister les erreurs,
• gérer l’accessibilité (aria-invalid, aria-describedby).
1export type ListOfErrors = Array<string | null | undefined> | null | undefined23export function Field({4labelProps,5inputProps,6errors7}: {8labelProps: React.LabelHTMLAttributes<HTMLLabelElement>9inputProps: React.InputHTMLAttributes<HTMLInputElement>10errors?: ListOfErrors11}) {12return (13<div className="flex flex-col gap-1">14<label {...labelProps} />15<input16{...inputProps}17aria-invalid={Boolean(errors?.length)}18aria-describedby={errors?.length ? `${inputProps.id}-error` : undefined}19className="px-3 py-2 border rounded-md focus:ring-2 focus:ring-blue-500"20/>21{errors?.length ? (22<ul id={`${inputProps.id}-error`} className="text-red-600 text-sm">23{errors.map(e => <li key={e}>{e}</li>)}24</ul>25) : null}26</div>27)28}
⚡️ Avantage : tu ne réécriras plus jamais
<label>ni règles ARIA.
5. Brancher le formulaire sur Field
1<Form method="POST" className="space-y-3">2<Field3labelProps={{ children: "Prénom", htmlFor: "firstName" }}4inputProps={{5id: "firstName",6name: "firstName",7type: "text",8defaultValue: user?.firstName ?? ""9}}10errors={actionData?.errors.firstName}11/>1213<Field14labelProps={{ children: "Nom", htmlFor: "lastName" }}15inputProps={{16id: "lastName",17name: "lastName",18type: "text",19defaultValue: user?.lastName ?? ""20}}21errors={actionData?.errors.lastName}22/>2324<button25type="submit"26disabled={isSubmitting}27className="px-4 py-2 rounded-md bg-blue-600 text-white28disabled:opacity-50 transition-colors"29>30{isSubmitting ? "En cours…" : isNew ? "Ajouter" : "Modifier"}31</button>32</Form>
Résultat :
- l’utilisateur conserve sa saisie,
- chaque champ signale son erreur,
- le bouton passe en grisé pendant 250 ms (delay simulé côté serveur).
6. Pourquoi cette approche améliore la DX/UX ?
| Avant | Après |
|---|---|
| Redirection 302 sur erreur ⇒ perte du formulaire | Retour JSON 400 ⇒ pas de reload |
| Une erreur par requête (early return) | Plusieurs erreurs agrégées |
| Input géré à la main, pas accessible | Field DRY et ARIA-ready |
| Pas d’indicateur de chargement | useNavigation désactive le bouton |
Tu viens d’écrire 0 ligne de state local tout en offrant une UX proche de Stripe Checkout.
Points clés à retenir
- Action → JSON → useActionData : le triplet magique pour les messages
d’erreur granularisés. Field.tsxconcentre le pattern label + input + errors et se réutilise partout.useNavigation().state === "submitting"suffit pour gérer le loader bouton.- Tout est encore vanilla ; Zod et Conform automatiseront la validation et le focus dans la prochaine leçon.
Exercices rapides
-
Ajoute la suppression d’un utilisateur
Crée un bouton “Supprimer” qui envoie_method=deleteet affiche un toast de confirmation siactionrenvoie{ deleted: true }. -
Affichage inline
ModifieFieldpour entourer l’input deborder-red-600lorsqueerrors?.length> 0. -
Accessibilité avancée
Utilise l’APIuseEffectpour placer automatiquement le focus sur le premier champ en erreur (pense au ref surinputProps.id).
Prends le temps de tester ; la suite sera encore plus puissante avec Zod et Conform.
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 ?