frontend / react
React 19 : Actions, useActionState, useFormStatus, useOptimistic
Explication
Ce que vous allez apprendre
- Utiliser une Action React 19 pour gérer un formulaire sans
preventDefaultmanuel - Combiner action et résultat avec
useActionStateet son booléenpendingintégré - Lire le statut d'un formulaire ancêtre depuis un enfant avec
useFormStatus - Implémenter une UI optimiste avec rollback automatique via
useOptimistic - Comparer l'approche React 19 à la gestion manuelle de state pré-existante
Dans quel contexte ?
Le formulaire d'inscription src/components/SignupForm.jsx répète, comme tous les autres formulaires de l'application, le même trio useState pour l'état d'envoi, l'erreur et la réinitialisation — du code quasi identique dupliqué dans une dizaine de fichiers. Migrer ce formulaire vers useActionState élimine cette duplication : React gère automatiquement l'état pending et le résultat de l'action, sans qu'aucun useState supplémentaire ne soit nécessaire.
Étape 1 : le trio répétitif d'un formulaire
Un formulaire déclenche presque toujours une "mutation", une action qui modifie une donnée quelque part. Gérer cela implique le même trio répétitif : état d'envoi, erreur, retour à la normale.
Étape 2 : comment ce trio se gérait avant
Avant React 19, ce trio devait être recodé manuellement, avec plusieurs useState, dans chaque formulaire de l'application.
Étape 3 : la solution, les Actions
Les Actions introduisent un nouveau modèle : une simple fonction asynchrone passée directement à l'attribut action d'un <form>.
Étape 4 : ce que React gère automatiquement
React reçoit directement le FormData du formulaire soumis, sans event.preventDefault() manuel, et gère automatiquement l'état "en cours".
Étape 5 : useActionState, aller plus loin
useActionState couple cette action à un state qui reflète son résultat, avec un booléen pending fourni automatiquement, plus besoin de le gérer à la main.
Étape 6 : useFormStatus, éviter le prop-drilling
useFormStatus résout un problème propre aux formulaires : un composant enfant peut lire directement l'état du formulaire ancêtre, sans que ce statut lui soit transmis en prop.
Un dernier hook, useOptimistic
Il reste useOptimistic, qui officialise l'UI optimiste déjà vue dans une leçon précédente, avec un rollback automatique intégré en cas d'échec.
Bonne pratique
Préfère useActionState à un trio manuel de useState (pending/erreur/succès) dès que React 19 est disponible : le code est plus court, moins sujet aux oublis (comme oublier de remettre pending à false dans un finally), et l'API est cohérente entre tous les formulaires de l'application.
| Hook | Remplace | Bénéfice principal |
|---|---|---|
Action (<form action={fn}>) | onSubmit + preventDefault manuel | FormData reçu directement |
useActionState | useState pending/erreur en trio | État dérivé automatiquement |
useFormStatus | Prop pending transmise manuellement | Lecture directe depuis un enfant |
useOptimistic | UI optimiste + rollback fait main | Rollback automatique en cas d'échec |
Et ensuite ?
Une fois ces nouveautés de React 19 comprises, la prochaine étape aborde une technique différente : rendre du JSX en dehors de l'arbre DOM parent, avec les Portals.
Commandes & code
React 19 : Actions, useActionState, useFormStatus, useOptimistic
Gérer les mutations (formulaires, écritures serveur) sans réinventer loading/erreur/optimistic à la main.
// Avant React 19 : gestion manuelle de l'état d'une soumission
function FormulaireAvant() {
const [enEnvoi, setEnEnvoi] = useState(false);
const [erreur, setErreur] = useState(null);
async function gererSubmit(e) {
e.preventDefault();
setEnEnvoi(true);
setErreur(null);
try {
await enregistrerProfil(new FormData(e.target));
} catch (err) {
setErreur(err.message);
} finally {
setEnEnvoi(false);
}
}
return (
<form onSubmit={gererSubmit}>
<input name="nom" />
<button disabled={enEnvoi}>{enEnvoi ? "Envoi..." : "Enregistrer"}</button>
{erreur && <p role="alert">{erreur}</p>}
</form>
);
}// Actions : une <form action={fn}> où fn est une fonction async -- React gère pending/erreur/reset
function FormulaireAction() {
async function enregistrer(formData) {
// reçoit directement le FormData, pas un event -- pas de preventDefault nécessaire
const nom = formData.get("nom");
await fetch("/api/profil", { method: "POST", body: JSON.stringify({ nom }) });
}
return (
<form action={enregistrer}>
<input name="nom" />
<button type="submit">Enregistrer</button>
</form>
);
}// useActionState : combine l'action avec un state dérivé de son résultat (succès/erreur)
import { useActionState } from "react";
async function creerCompte(etatPrecedent, formData) {
const email = formData.get("email");
if (!email.includes("@")) {
return { erreur: "Email invalide" }; // devient le nouvel "etat"
}
await fetch("/api/comptes", { method: "POST", body: JSON.stringify({ email }) });
return { succes: true };
}
function FormulaireInscription() {
const [etat, action, enTransition] = useActionState(creerCompte, { erreur: null });
// etat : dernier retour de l'action | action : à passer à <form action> | enTransition : pending automatique
return (
<form action={action}>
<input name="email" />
<button disabled={enTransition}>{enTransition ? "Envoi..." : "S'inscrire"}</button>
{etat.erreur && <p role="alert">{etat.erreur}</p>}
</form>
);
}// useFormStatus : lit le statut du <form> parent depuis un composant enfant, sans prop-drilling
import { useFormStatus } from "react-dom";
function BoutonSoumettre() {
const { pending } = useFormStatus(); // lit AUTOMATIQUEMENT le form ancêtre le plus proche
return <button disabled={pending}>{pending ? "Envoi..." : "Valider"}</button>;
}
function Formulaire() {
return (
<form action={creerCompte}>
<input name="email" />
<BoutonSoumettre /> {/* pas besoin de recevoir "pending" en prop */}
</form>
);
}// useOptimistic : afficher le résultat probable AVANT la confirmation serveur, avec rollback automatique
import { useOptimistic } from "react";
function ListeCommentaires({ commentaires, ajouterCommentaire }) {
const [commentairesOptimistes, ajouterOptimiste] = useOptimistic(
commentaires,
(etat, nouveauTexte) => [...etat, { id: "temp", texte: nouveauTexte, enAttente: true }]
);
async function gererAction(formData) {
const texte = formData.get("texte");
ajouterOptimiste(texte); // affiché immédiatement, marqué "enAttente"
await ajouterCommentaire(texte); // si ça échoue, React revient automatiquement à "commentaires"
}
return (
<>
<ul>
{commentairesOptimistes.map((c) => (
<li key={c.id} style={{ opacity: c.enAttente ? 0.5 : 1 }}>{c.texte}</li>
))}
</ul>
<form action={gererAction}>
<input name="texte" />
<button type="submit">Publier</button>
</form>
</>
);
}Résumé
<form action={fn}>reçoit unFormDatadirectement, sansevent.preventDefault()manuel.useActionStatefusionne l'action et le state résultant (erreur, succès), avec un booléenpendingintégré.useFormStatuslit le statut du formulaire ancêtre depuis n'importe quel enfant, sans proppendingà transmettre.useOptimisticaffiche un résultat anticipé et revient automatiquement en arrière si l'action échoue.
Exercices pratiques
Mission : éliminer le trio dupliqué de SignupForm
Objectif : Migrer un formulaire du trio useState (pending/erreur/succès) vers useActionState, puis exposer son statut à un enfant sans prop-drilling.
Contexte
SignupForm.jsx répète, comme neuf autres formulaires de l'application, le même trio const [enEnvoi, setEnEnvoi] = useState(false) / const [erreur, setErreur] = useState(null) / gestion manuelle du finally. Un audit récent a même trouvé un formulaire où l'appel à setEnEnvoi(false) avait été oublié dans le finally, laissant le bouton bloqué sur "Envoi..." indéfiniment après une erreur réseau. Il faut migrer SignupForm vers useActionState, et faire lire son statut par un composant enfant BoutonSoumettre sans lui transmettre pending en prop.