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.
avec React Router 7
Posez vos questions 24/7 à notre IA experte en React Router 7
Validez vos acquis avec des quiz personnalisés et un feedback instantané
<Form>
basique au champ haut-de-gammeLe 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 à :
action
riche (succès + erreurs),useActionData
,Field
réutilisable qui gère label, input, accessibilité et messages,useNavigation
.On reste sans dépendance externe. Zod & Conform arriveront dans la prochaine leçon pour automatiser toute la validation !
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}
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}
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.
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.
1import { useNavigation } from "react-router"23const navigation = useNavigation()4const isSubmitting = navigation.state === 'submitting'
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.
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 :
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.
Field.tsx
concentre le pattern label + input + errors et se réutilise partout.useNavigation().state === "submitting"
suffit pour gérer le loader bouton.Ajoute la suppression d’un utilisateur
Crée un bouton “Supprimer” qui envoie _method=delete
et affiche un toast
de confirmation si action
renvoie { deleted: true }
.
Affichage inline
Modifie Field
pour entourer l’input de border-red-600
lorsque
errors?.length
> 0.
Accessibilité avancée
Utilise l’API useEffect
pour placer automatiquement le focus sur le
premier champ en erreur (pense au ref sur inputProps.id
).
Prends le temps de tester ; la suite sera encore plus puissante avec Zod et Conform.
Quelle est la principale différence entre les composants client et serveur dans React ?
Quelle technique est recommandée pour éviter les rendus inutiles dans React ?
Quel hook permet de gérer les effets de bord dans un composant React ?
Comment implémenter la gestion des erreurs pour les requêtes API dans React ?
Quelle est la meilleure pratique pour déployer une application React en production ?