Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

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).

tsx
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 :

tsx
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 dans name (une « propriété calculée »). Les attributs name des 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 dans e.target.value, et une chaîne vide n'est pas un nombre. On convertit avec Number() à l'envoi.
  • e.preventDefault() empêche le comportement par défaut d'un formulaire HTML : envoyer une requête et recharger la page.
  • noValidate désactive les bulles de validation du navigateur pour que les messages viennent tous du composant. Sans lui, garde required, min et type : c'est une validation gratuite.
  • !(montant > 0) refuse aussi NaN (une saisie vide donne Number('') === 0, une saisie invalide donne NaN, et NaN > 0 est faux).

Les autres types de champs

ChampValeur contrôléeLire la saisie
<input type="text">, email, date, numbervalue={…}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 optionse.target.value
<input type="checkbox">checked={…}e.target.checked (un booléen)
<input type="radio">checked={choix === 'train'} sur chaque boutone.target.value du bouton coché
<input type="file">Toujours non contrôlée.target.files?.[0]
tsx
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 :

GestionnaireType du paramètre
onChange d'un inputChangeEvent<HTMLInputElement>
onChange d'un select ou d'un textareaChangeEvent<HTMLSelectElement>, ChangeEvent<HTMLTextAreaElement>
onSubmit d'un formFormEvent<HTMLFormElement>
onClick d'un boutonMouseEvent<HTMLButtonElement>
onKeyDownKeyboardEvent<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 :

tsx
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 :

  1. empêche le rechargement de la page (pas de preventDefault à écrire) ;
  2. rassemble les champs dans un objet FormData (chaque champ doit avoir un name), lu avec formData.get('nom') ;
  3. appelle la fonction dans une transition : c'est une Action, dont React suit l'état « en cours » ;
  4. 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 :

tsx
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) ;
  • etat est la dernière valeur renvoyée (au départ, etatInitial) ;
  • action se passe au formulaire : <form action={action}> ;
  • enCours vaut true pendant 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) :

tsx
// 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 :

SituationCe 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 201Le 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 :

tsx
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 :

tsx
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).

tsx
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>
    </>
  );
}
clic sur AjouterTout de suite, sans attendre l'APIla liste affiche « Taxi : 18.4 € (envoi…) »le bouton passe à « Envoi… » et se désactiveajouterOptimiste(…) · useFormStatus().pendingPOST /fiches/12/frais-hors-forfait201 Created409 ConflictL'API accepte• la vraie liste reçoit le frais créé• « (envoi…) » disparaît• le formulaire se vide• le bouton redevient « Ajouter »L'API refuse• le message d'erreur s'affiche• la ligne optimiste disparaît :la liste revient à la valeur réelle• le bouton redevient actifLa valeur optimiste ne vit que le temps de l'Action, puis la valeur réelle reprend.
Une Action avec mise à jour optimiste : affichage immédiat, puis confirmation ou retour en arrière

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 dans startTransition : en React 19, une mise à jour d'état placée après un await dans 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 ?

BesoinChamps contrôlésAction + useActionState
Réagir à chaque frappe (compteur, aperçu, mise en forme)OuiNon (les valeurs ne sont lues qu'à l'envoi)
Activer un bouton selon la saisie en coursOuiSeulement 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 codeUn état et un onChange par champUn name par champ
Remise à zéro après envoiÀ écrireAutomatique

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
tsx
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.

tsx
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
tsx
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>
  );
}
  1. Il manquait onChange : avec value seul, le champ est figé.
  2. Number(montant) convertit la chaîne. TypeScript signalait déjà l'erreur : string n'est pas assignable à number.
  3. e.preventDefault() empêche le rechargement. Au passage, le champ reçoit une étiquette, et step="0.01" autorise les centimes (par défaut, un champ number n'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é.

  1. Ajouter un frais hors forfait de 12 € à sa fiche en cours.
  2. Le comptable valide une fiche et fixe le montant remboursé.
  3. Le visiteur coche « justificatif envoyé » sur un frais.
  4. Supprimer définitivement un frais.
Voir la réponseMasquer la réponse de l'exercice 4
  1. Oui : l'action réussit presque toujours, et en cas d'échec la ligne disparaît avec un message clair.
  2. 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.
  3. Oui : simple, fréquent, réversible.
  4. 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é ?

Chapitre terminé !

Fais les 4 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : TanStack Query (React Query) : charger et mettre en cache les donnéesRevoir le sommaire du cours