Chapitre 826 minIntermédiaire
Formulaires React : champs contrôlés, Actions et useActionState
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- écrire un formulaire à champs contrôlés avec un seul gestionnaire ;
- envoyer un formulaire avec une Action et useActionState ;
- afficher l'état d'envoi et les erreurs de l'API sans perdre la saisie ;
- afficher un résultat optimiste avec useOptimistic ;
- expliquer pourquoi l'API doit revalider chaque saisie.
4 exercices corrigés1 schéma
Un formulaire React s'écrit de deux façons. Avec des champs contrôlés, chaque frappe met à jour l'état du composant, qui reste la seule source de vérité. Avec les Actions de React 19, on passe une fonction à <form action={…}> qui reçoit les données du formulaire à l'envoi, et les hooks useActionState, useFormStatus et useOptimistic gèrent l'état d'envoi, les erreurs et l'affichage immédiat. Ce chapitre construit le formulaire d'ajout d'un frais d'AppliFrais avec les deux méthodes et dit quand choisir l'une ou l'autre.
Le champ contrôlé
En HTML, un <input> garde sa valeur tout seul, dans le DOM. Un champ contrôlé confie cette valeur à React : le champ affiche l'état (value), et chaque frappe met l'état à jour (onChange).
const [libelle, setLibelle] = useState('');
<input id="libelle" value={libelle} onChange={(e) => setLibelle(e.target.value)} />
Le cycle : tu tapes une lettre → onChange reçoit la nouvelle valeur → setLibelle la range dans l'état → React redessine → le champ affiche l'état. L'état est la seule source de vérité : à tout moment, libelle contient exactement ce qu'affiche le champ, et tu peux t'en servir pendant la saisie (compteur de caractères, bouton désactivé tant que le champ est vide, mise en majuscules).
Un formulaire à plusieurs champs
Un état par champ devient lourd au-delà de deux champs. On regroupe la saisie dans un objet, et un seul gestionnaire met à jour le bon champ grâce à son attribut name :
import { useState, type ChangeEvent, type FormEvent } from 'react';
import type { NouveauFrais } from '../types';
type Saisie = { date: string; libelle: string; montant: string };
const VIDE: Saisie = { date: '', libelle: '', montant: '' };
export function FormulaireControle({ onAjouter }: { onAjouter: (frais: NouveauFrais) => void }) {
const [saisie, setSaisie] = useState<Saisie>(VIDE);
const [erreur, setErreur] = useState<string | null>(null);
function changer(e: ChangeEvent<HTMLInputElement>) {
const { name, value } = e.target;
setSaisie({ ...saisie, [name]: value }); // [name] : la clé vient de l'attribut name du champ
}
function soumettre(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); // sinon le navigateur recharge la page
const montant = Number(saisie.montant);
if (saisie.libelle.trim() === '') return setErreur('Le libellé est obligatoire.');
if (!(montant > 0)) return setErreur('Le montant doit être supérieur à 0.');
onAjouter({ date: saisie.date, libelle: saisie.libelle.trim(), montant });
setSaisie(VIDE);
setErreur(null);
}
const restants = 50 - saisie.libelle.length;
return (
<form onSubmit={soumettre} noValidate>
<label htmlFor="c-date">Date</label>
<input id="c-date" name="date" type="date" value={saisie.date} onChange={changer} />
<label htmlFor="c-libelle">Libellé</label>
<input id="c-libelle" name="libelle" maxLength={50} value={saisie.libelle} onChange={changer} />
<p>{restants} caractères restants</p>
<label htmlFor="c-montant">Montant (€)</label>
<input id="c-montant" name="montant" type="number" step="0.01" value={saisie.montant} onChange={changer} />
{erreur && <p role="alert">{erreur}</p>}
<button type="submit">Ajouter</button>
</form>
);
}
Points à retenir :
{ ...saisie, [name]: value }copie l'objet et remplace la propriété dont le nom est dansname(une « propriété calculée »). Les attributsnamedes champs doivent donc correspondre aux clés de l'objet.- Le montant est gardé en chaîne pendant la saisie : un champ
type="number"donne toujours une chaîne danse.target.value, et une chaîne vide n'est pas un nombre. On convertit avecNumber()à l'envoi. e.preventDefault()empêche le comportement par défaut d'un formulaire HTML : envoyer une requête et recharger la page.noValidatedésactive les bulles de validation du navigateur pour que les messages viennent tous du composant. Sans lui, garderequired,minettype: c'est une validation gratuite.!(montant > 0)refuse aussiNaN(une saisie vide donneNumber('') === 0, une saisie invalide donneNaN, etNaN > 0est faux).
Les autres types de champs
| Champ | Valeur contrôlée | Lire la saisie |
|---|---|---|
<input type="text">, email, date, number | value={…} | e.target.value (toujours une chaîne) |
<textarea> | value={…} (pas de texte entre les balises) | e.target.value |
<select> | value={…} sur le select, pas selected sur les options | e.target.value |
<input type="checkbox"> | checked={…} | e.target.checked (un booléen) |
<input type="radio"> | checked={choix === 'train'} sur chaque bouton | e.target.value du bouton coché |
<input type="file"> | Toujours non contrôlé | e.target.files?.[0] |
const [justificatif, setJustificatif] = useState(false);
<input
id="justificatif"
type="checkbox"
checked={justificatif}
onChange={(e) => setJustificatif(e.target.checked)}
/>
<label htmlFor="justificatif">Justificatif fourni</label>
Typer les événements
Un gestionnaire écrit dans l'attribut (onChange={(e) => …}) reçoit un type déduit automatiquement. Un gestionnaire écrit à part doit être typé. Les types viennent de react :
| Gestionnaire | Type du paramètre |
|---|---|
onChange d'un input | ChangeEvent<HTMLInputElement> |
onChange d'un select ou d'un textarea | ChangeEvent<HTMLSelectElement>, ChangeEvent<HTMLTextAreaElement> |
onSubmit d'un form | FormEvent<HTMLFormElement> |
onClick d'un bouton | MouseEvent<HTMLButtonElement> |
onKeyDown | KeyboardEvent<HTMLInputElement> |
Astuce : survole onChange dans ton éditeur, il affiche le type attendu. Importe les types avec import { type ChangeEvent } from 'react'.
Valider : côté client pour le confort, côté serveur pour la sécurité
La validation dans le navigateur (required, min, les tests du composant) sert à aider l'utilisateur : il voit son erreur tout de suite, sans aller-retour avec le serveur. Elle ne protège rien : n'importe qui peut désactiver JavaScript, modifier le code dans les outils du navigateur ou appeler l'API directement avec curl. L'API doit toujours revalider chaque donnée reçue ; c'est elle qui renvoie 422 pour un montant négatif, et le formulaire doit savoir afficher ce message.
Les Actions de React 19
React 19 a ajouté une autre façon d'écrire les formulaires. On passe une fonction, éventuellement asynchrone, à l'attribut action du formulaire :
async function enregistrer(formData: FormData) {
const frais = {
date: String(formData.get('date')),
libelle: String(formData.get('libelle')),
montant: Number(formData.get('montant')),
};
await ajouterFrais(12, frais);
}
<form action={enregistrer}>
<input name="date" type="date" required />
<input name="libelle" required />
<input name="montant" type="number" step="0.01" required />
<button type="submit">Ajouter</button>
</form>
À l'envoi, React :
- empêche le rechargement de la page (pas de
preventDefaultà écrire) ; - rassemble les champs dans un objet
FormData(chaque champ doit avoir unname), lu avecformData.get('nom'); - appelle la fonction dans une transition : c'est une Action, dont React suit l'état « en cours » ;
- quand l'Action se termine, remet le formulaire à zéro (les champs non contrôlés reprennent leur valeur par défaut).
Les champs ne sont plus contrôlés : pas d'état par champ, pas d'onChange. Le navigateur garde les valeurs, et on les lit une fois, à l'envoi. formData.get() renvoie une chaîne (ou un fichier, ou null) : convertis les nombres toi-même.
useActionState : l'état d'un formulaire qui envoie
Une Action seule ne dit pas comment afficher une erreur ou désactiver le bouton pendant l'envoi. useActionState enveloppe la fonction et fournit les trois :
const [etat, action, enCours] = useActionState(fonction, etatInitial);
fonction(etatPrecedent, formData)reçoit l'état précédent et les données, et renvoie le nouvel état (par exemple un message d'erreur) ;etatest la dernière valeur renvoyée (au départ,etatInitial) ;actionse passe au formulaire :<form action={action}>;enCoursvauttruependant l'exécution.
Voici le formulaire définitif d'AppliFrais. Il valide la saisie, appelle l'API, et en cas d'erreur réaffiche ce que l'utilisateur avait tapé (sinon la remise à zéro automatique l'effacerait) :
// src/components/FormulaireFrais.tsx
import { useActionState } from 'react';
import { ajouterFrais } from '../api/fiches';
import { ApiError } from '../api/client';
import type { NouveauFrais } from '../types';
type EtatFormulaire = {
erreur: string | null;
saisie: NouveauFrais; // pour réafficher la saisie après une erreur
};
const VIDE: NouveauFrais = { date: '', libelle: '', montant: 0 };
export function FormulaireFrais({ ficheId }: { ficheId: number }) {
async function enregistrer(_precedent: EtatFormulaire, formData: FormData): Promise<EtatFormulaire> {
const saisie: NouveauFrais = {
date: String(formData.get('date')),
libelle: String(formData.get('libelle')).trim(),
montant: Number(formData.get('montant')),
};
if (saisie.libelle === '') return { erreur: 'Le libellé est obligatoire.', saisie };
if (!(saisie.montant > 0)) return { erreur: 'Le montant doit être supérieur à 0.', saisie };
try {
await ajouterFrais(ficheId, saisie);
return { erreur: null, saisie: VIDE };
} catch (e) {
const message = e instanceof ApiError ? e.message : 'Le serveur ne répond pas, réessaie.';
return { erreur: message, saisie };
}
}
const [etat, action, enCours] = useActionState(enregistrer, { erreur: null, saisie: VIDE });
return (
<form action={action} aria-describedby={etat.erreur ? 'erreur-frais' : undefined}>
<h2>Ajouter un frais hors forfait</h2>
<p>
<label htmlFor="date">Date</label>
<input id="date" name="date" type="date" required defaultValue={etat.saisie.date} />
</p>
<p>
<label htmlFor="libelle">Libellé</label>
<input id="libelle" name="libelle" required defaultValue={etat.saisie.libelle} />
</p>
<p>
<label htmlFor="montant">Montant (€)</label>
<input id="montant" name="montant" type="number" step="0.01" min="0.01" required
defaultValue={etat.saisie.montant || ''} />
</p>
{etat.erreur && <p id="erreur-frais" role="alert">{etat.erreur}</p>}
<button type="submit" disabled={enCours}>
{enCours ? 'Enregistrement…' : 'Ajouter le frais'}
</button>
</form>
);
}
Ce qui se passe pour l'utilisateur :
| Situation | Ce qu'il voit |
|---|---|
| Il clique sur « Ajouter le frais » | Le bouton passe à « Enregistrement… » et se désactive : pas de double envoi |
L'API répond 201 | Le formulaire se vide ; le nouveau frais apparaît dans la liste au chapitre 9 |
L'API répond 409 | « La fiche est à l'état CL : seuls les frais d'une fiche en cours se modifient. » s'affiche, sa saisie est conservée |
| Le serveur est injoignable | « Le serveur ne répond pas, réessaie. », saisie conservée |
defaultValue (et non value) donne la valeur initiale d'un champ non contrôlé : React l'utilise quand il remet le formulaire à zéro. En renvoyant la saisie dans l'état, on la fait réapparaître après une erreur.
useFormStatus : savoir qu'un formulaire envoie, depuis un composant enfant
Un bouton d'envoi réutilisable peut lire l'état du formulaire qui le contient avec useFormStatus, importé de react-dom :
import { useFormStatus } from 'react-dom';
export function BoutonEnvoyer({ libelle }: { libelle: string }) {
const { pending } = useFormStatus(); // l'état du <form> parent
return <button type="submit" disabled={pending}>{pending ? 'Envoi…' : libelle}</button>;
}
useFormStatus ne fonctionne que dans un composant placé à l'intérieur d'un <form> qui utilise une Action. Appelé dans le composant qui écrit le <form>, il renvoie toujours pending: false.
useOptimistic : afficher le résultat avant la réponse
Sur un réseau lent, attendre la réponse du serveur pour afficher un frais qu'on vient de saisir donne une impression de lenteur. Une mise à jour optimiste l'affiche tout de suite, comme si l'API avait déjà accepté, puis le remplace par la vraie valeur quand elle arrive, ou le retire en cas d'échec. C'est le rôle de useOptimistic :
const [valeurAffichee, ajouterOptimiste] = useOptimistic(valeurReelle, fusion);
Pendant qu'une Action est en cours, valeurAffichee vaut la valeur réelle plus les ajouts optimistes ; quand l'Action se termine, elle redevient la valeur réelle (qui contient alors, si tout s'est bien passé, le frais confirmé par l'API).
import { startTransition, useOptimistic, useState } from 'react';
import { ajouterFrais } from '../api/fiches';
import { BoutonEnvoyer } from './BoutonEnvoyer';
import type { Frais, NouveauFrais } from '../types';
type FraisAffiche = Frais & { enAttente?: boolean };
export function FraisOptimistes({ ficheId, fraisInitiaux }: { ficheId: number; fraisInitiaux: Frais[] }) {
const [frais, setFrais] = useState<Frais[]>(fraisInitiaux);
const [erreur, setErreur] = useState<string | null>(null);
const [fraisAffiches, ajouterOptimiste] = useOptimistic<FraisAffiche[], FraisAffiche>(
frais,
(liste, nouveau) => [...liste, nouveau],
);
async function action(formData: FormData) {
const saisie: NouveauFrais = {
date: String(formData.get('date')),
libelle: String(formData.get('libelle')),
montant: Number(formData.get('montant')),
};
setErreur(null);
ajouterOptimiste({ ...saisie, id: -Date.now(), enAttente: true }); // affiché tout de suite
try {
const cree = await ajouterFrais(ficheId, saisie);
startTransition(() => setFrais((liste) => [...liste, cree])); // la vraie valeur
} catch (e) {
setErreur(e instanceof Error ? e.message : 'Échec de l’enregistrement.');
// rien d'autre à faire : la ligne optimiste disparaît quand l'action se termine
}
}
return (
<>
<ul>
{fraisAffiches.map((f) => (
<li key={f.id}>
{f.libelle} : {f.montant} €{f.enAttente && ' (envoi…)'}
</li>
))}
</ul>
{erreur && <p role="alert">{erreur}</p>}
<form action={action}>
<input name="date" type="date" aria-label="Date" required />
<input name="libelle" aria-label="Libellé" required />
<input name="montant" type="number" step="0.01" aria-label="Montant" required />
<BoutonEnvoyer libelle="Ajouter" />
</form>
</>
);
}
Trois détails comptent :
- l'identifiant provisoire est négatif (
-Date.now()) : il ne peut pas entrer en conflit avec un identifiant de la base, et il sert de clé à la ligne en attente ; - la mise à jour de la vraie liste, après un
await, est enveloppée dansstartTransition: en React 19, une mise à jour d'état placée après unawaitdans une Action n'est pas automatiquement rattachée à la transition, et ce code garde la liste réelle et la liste optimiste cohérentes jusqu'à la fin de l'Action ; - l'erreur est attrapée dans l'Action : une exception non attrapée dans une Action remonte jusqu'à la frontière d'erreur la plus proche et remplace l'écran par la page d'erreur.
Contrôlé ou Action : que choisir ?
| Besoin | Champs contrôlés | Action + useActionState |
|---|---|---|
| Réagir à chaque frappe (compteur, aperçu, mise en forme) | Oui | Non (les valeurs ne sont lues qu'à l'envoi) |
| Activer un bouton selon la saisie en cours | Oui | Seulement avec la validation native (required) |
| Envoyer à une API, avec état d'envoi et erreur | À écrire à la main (trois états) | Fourni : enCours, état renvoyé |
| Quantité de code | Un état et un onChange par champ | Un name par champ |
| Remise à zéro après envoi | À écrire | Automatique |
Les deux se combinent : un formulaire en Action peut contenir un champ contrôlé (le libellé, pour afficher son compteur). Dans ce cours, le formulaire d'ajout utilise une Action ; les filtres, qui réagissent immédiatement, sont contrôlés.
Exercices
Exercice 1 sur 4 Le formulaire de connexion Réussi À revoir
Écris un formulaire de connexion avec une Action et useActionState. Il a deux champs, login et motDePasse, et appelle connexion(login, motDePasse) (qui lève une ApiError de statut 401 si les identifiants sont faux). L'état est le message d'erreur (string | null). Affiche « Identifiant ou mot de passe incorrect. » pour un 401, « Connexion impossible pour le moment, réessaie. » pour toute autre erreur. Le bouton est désactivé pendant l'envoi.
Voir la réponseMasquer la réponse de l'exercice 1
import { useActionState } from 'react';
import { connexion } from '../api/auth';
import { ApiError } from '../api/client';
export function FormulaireConnexion() {
const [erreur, action, enCours] = useActionState(
async (_precedente: string | null, formData: FormData) => {
try {
await connexion(String(formData.get('login')), String(formData.get('motDePasse')));
return null;
} catch (e) {
return e instanceof ApiError && e.status === 401
? 'Identifiant ou mot de passe incorrect.'
: 'Connexion impossible pour le moment, réessaie.';
}
},
null,
);
return (
<form action={action}>
<label htmlFor="login">Identifiant</label>
<input id="login" name="login" autoComplete="username" required />
<label htmlFor="motDePasse">Mot de passe</label>
<input id="motDePasse" name="motDePasse" type="password" autoComplete="current-password" required />
{erreur && <p role="alert">{erreur}</p>}
<button type="submit" disabled={enCours}>{enCours ? 'Connexion…' : 'Se connecter'}</button>
</form>
);
}
Le message ne dit pas si c'est l'identifiant ou le mot de passe qui est faux : le préciser aiderait un attaquant à deviner les identifiants existants. La version complète, qui redirige après la connexion, est au chapitre 12.
Tu avais trouvé ?
Exercice 2 sur 4 Corriger un champ contrôlé Réussi À revoir
Trois bogues : le champ ne se laisse pas modifier ; le montant envoyé vaut "18.4" au lieu de 18.4 ; la page se recharge à l'envoi. Corrige-les.
function SaisieMontant({ onValider }: { onValider: (m: number) => void }) {
const [montant, setMontant] = useState('');
return (
<form onSubmit={() => onValider(montant)}>
<input type="number" value={montant} />
<button>Valider</button>
</form>
);
}
Voir la réponseMasquer la réponse de l'exercice 2
function SaisieMontant({ onValider }: { onValider: (m: number) => void }) {
const [montant, setMontant] = useState('');
return (
<form onSubmit={(e) => { e.preventDefault(); onValider(Number(montant)); }}>
<label htmlFor="saisie-montant">Montant</label>
<input id="saisie-montant" type="number" step="0.01" value={montant}
onChange={(e) => setMontant(e.target.value)} />
<button type="submit">Valider</button>
</form>
);
}
- Il manquait
onChange: avecvalueseul, le champ est figé. Number(montant)convertit la chaîne. TypeScript signalait déjà l'erreur :stringn'est pas assignable ànumber.e.preventDefault()empêche le rechargement. Au passage, le champ reçoit une étiquette, etstep="0.01"autorise les centimes (par défaut, un champnumbern'accepte que des entiers).
Tu avais trouvé ?
Exercice 3 sur 4 Ce que fait la remise à zéro Réussi À revoir
Dans FormulaireFrais, un étudiant a remplacé defaultValue={etat.saisie.libelle} par defaultValue="" sur les trois champs. Que constate l'utilisateur quand l'API répond 422 ? Pourquoi ?
Voir la réponseMasquer la réponse de l'exercice 3
Le message d'erreur s'affiche, mais les trois champs sont vides : l'utilisateur doit tout ressaisir. Quand une Action de formulaire se termine, React remet le formulaire à zéro, que l'Action ait réussi ou non ; chaque champ non contrôlé reprend sa defaultValue. En renvoyant la saisie dans l'état et en l'utilisant comme defaultValue, on la fait réapparaître après une erreur ; après un succès, l'état renvoie VIDE et les champs se vident.
Tu avais trouvé ?
Exercice 4 sur 4 Optimiste ou pas ? Réussi À revoir
Pour chaque action d'AppliFrais, dis si un affichage optimiste est adapté.
- Ajouter un frais hors forfait de 12 € à sa fiche en cours.
- Le comptable valide une fiche et fixe le montant remboursé.
- Le visiteur coche « justificatif envoyé » sur un frais.
- Supprimer définitivement un frais.
Voir la réponseMasquer la réponse de l'exercice 4
- Oui : l'action réussit presque toujours, et en cas d'échec la ligne disparaît avec un message clair.
- Non : c'est une décision qui engage l'entreprise (un remboursement). On attend la confirmation de l'API, avec un bouton « Validation… » pendant l'envoi.
- Oui : simple, fréquent, réversible.
- Plutôt non, ou alors avec une demande de confirmation avant : si l'API refuse (fiche clôturée entre-temps), faire réapparaître une ligne que l'utilisateur croyait supprimée est déroutant. On peut griser la ligne pendant la requête.
Tu avais trouvé ?