Retour au cours

frontend / react

React 19 : Actions, useActionState, useFormStatus, useOptimistic

Leçon 251 exercice

Explication

Ce que vous allez apprendre

  • Utiliser une Action React 19 pour gérer un formulaire sans preventDefault manuel
  • Combiner action et résultat avec useActionState et son booléen pending inté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.

HookRemplaceBénéfice principal
Action (<form action={fn}>)onSubmit + preventDefault manuelFormData reçu directement
useActionStateuseState pending/erreur en trioÉtat dérivé automatiquement
useFormStatusProp pending transmise manuellementLecture directe depuis un enfant
useOptimisticUI optimiste + rollback fait mainRollback 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.

jsx
// 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>
    );
}
jsx
// 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>
    );
}
jsx
// 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>
    );
}
jsx
// 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>
    );
}
jsx
// 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 un FormData directement, sans event.preventDefault() manuel.
  • useActionState fusionne l'action et le state résultant (erreur, succès), avec un booléen pending intégré.
  • useFormStatus lit le statut du formulaire ancêtre depuis n'importe quel enfant, sans prop pending à transmettre.
  • useOptimistic affiche un résultat anticipé et revient automatiquement en arrière si l'action échoue.

Exercices pratiques

1 disponible
1

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.

Résoudre l’exercice →