Retour au cours

frontend / react

État complexe avec useReducer

Leçon 121 exercice

Explication

Ce que vous allez apprendre

  • Identifier quand un state devient trop complexe pour rester géré par plusieurs useState
  • Écrire un reducer pur suivant la signature (etat, action) => nouvelEtat
  • Déclencher des transitions d'état avec dispatch({ type, payload })
  • Éviter la mutation directe de l'état à l'intérieur d'un reducer
  • Choisir entre useState et useReducer selon la complexité réelle du state

Dans quel contexte ?

Le composant src/components/ShoppingCart.jsx gère un panier avec plusieurs useState séparés (articles, code promo, frais de livraison) dont les mises à jour doivent rester cohérentes entre elles : ajouter un article recalcule le total, appliquer un code promo doit revalider les frais de livraison. Chaque fonction qui modifie l'état répète la même logique de recalcul, avec un risque d'oubli à chaque nouvel endroit. Centraliser ces règles dans un reducer panierReducer unique élimine cette duplication et rend chaque transition testable isolément.

Étape 1 : quand useState fonctionne bien

useState fonctionne très bien pour des valeurs isolées et indépendantes.

Étape 2 : la limite qui apparaît avec des champs liés

Dès qu'un état est composé de plusieurs champs liés entre eux par des règles métier, multiplier les useState séparés oblige à répéter la logique de cohérence dans chaque fonction qui modifie l'état.

Étape 3 : la solution, un reducer

Un reducer est une fonction avec une signature précise : elle reçoit l'état actuel et une "action" décrivant ce qu'il s'est passé, et retourne le nouvel état — (etat, action) => nouvelEtat.

Étape 4 : pourquoi centraliser cette logique

Cette idée vient de la programmation fonctionnelle : toutes les règles de transition de l'état sont regroupées à un seul endroit, au lieu d'être éparpillées dans chaque handler.

Étape 5 : comment useReducer s'utilise

useReducer(reducer, etatInitial) retourne l'état actuel et une fonction dispatch. Les composants appellent dispatch({ type, payload }) pour décrire l'action, et le reducer décide comment l'état évolue.

Étape 6 : une règle stricte à respecter

Un reducer doit toujours rester une fonction pure : jamais de mutation directe de l'état reçu, jamais d'effet de bord à l'intérieur.

Piège fréquent

case "AJOUTER": etat.articles.push(action.payload); return etat; mute directement le tableau existant. React compare les références : puisque etat reste le même objet, aucun re-rendu n'est déclenché. Toujours retourner un nouvel objet, comme { ...etat, articles: [...etat.articles, action.payload] }.

SituationOutil recommandé
Quelques valeurs indépendantes (ex: un booléen, un texte)useState
Plusieurs champs liés par des règles métier communesuseReducer
Transitions d'état nombreuses et prévisibles (ex: machine à états)useReducer

Pourquoi c'est utile, et sa limite

Ce pattern rend les transitions prévisibles et faciles à tester. Il ne faut cependant pas en abuser : pour un état simple, useReducer ajoute plus de cérémonie qu'un useState classique.

Et ensuite ?

Une fois les transitions d'état complexes maîtrisées, la prochaine étape s'attaque à la performance avec memo et la virtualisation de listes.

Commandes & code

useReducer

Pour un state avec plusieurs sous-valeurs liées ou des transitions complexes, un reducer centralise la logique.

jsx
import { useReducer } from "react";

// reducer : fonction PURE (etat, action) -> nouvel état, jamais de mutation ni d'effet de bord
function panierReducer(etat, action) {
    switch (action.type) {
        case "AJOUTER":
            return {
                ...etat,
                articles: [...etat.articles, action.payload],
            };
        case "SUPPRIMER":
            return {
                ...etat,
                articles: etat.articles.filter((a) => a.id !== action.payload.id),
            };
        case "MODIFIER_QUANTITE":
            return {
                ...etat,
                articles: etat.articles.map((a) =>
                    a.id === action.payload.id ? { ...a, quantite: action.payload.quantite } : a
                ),
            };
        case "VIDER":
            return { ...etat, articles: [] };
        default:
            throw new Error(`Action inconnue : ${action.type}`);
    }
}

const etatInitial = { articles: [] };

function Panier() {
    const [etat, dispatch] = useReducer(panierReducer, etatInitial);

    const total = etat.articles.reduce((s, a) => s + a.prix * a.quantite, 0);

    return (
        <div>
            <ul>
                {etat.articles.map((a) => (
                    <li key={a.id}>
                        {a.nom} x{a.quantite}
                        <button onClick={() => dispatch({ type: "SUPPRIMER", payload: { id: a.id } })}>
                            Retirer
                        </button>
                    </li>
                ))}
            </ul>
            <p>Total : {total}€</p>
            <button onClick={() => dispatch({ type: "VIDER" })}>Vider le panier</button>
        </div>
    );
}
jsx
// useReducer vs plusieurs useState : quand basculer
// Plusieurs useState indépendants deviennent pénibles dès que les transitions
// touchent PLUSIEURS champs à la fois, avec des règles métier entre eux
function formulaireReducer(etat, action) {
    switch (action.type) {
        case "CHANGER_CHAMP":
            return { ...etat, [action.champ]: action.valeur, erreurs: {} };
        case "SOUMETTRE_DEBUT":
            return { ...etat, enEnvoi: true, erreurs: {} };
        case "SOUMETTRE_SUCCES":
            return { ...etat, enEnvoi: false, envoye: true };
        case "SOUMETTRE_ECHEC":
            return { ...etat, enEnvoi: false, erreurs: action.erreurs };
        default:
            return etat;
    }
}

function Formulaire() {
    const [etat, dispatch] = useReducer(formulaireReducer, {
        email: "", mdp: "", enEnvoi: false, envoye: false, erreurs: {},
    });

    async function gererSubmit(e) {
        e.preventDefault();
        dispatch({ type: "SOUMETTRE_DEBUT" });
        try {
            await envoyerFormulaire(etat);
            dispatch({ type: "SOUMETTRE_SUCCES" });
        } catch (err) {
            dispatch({ type: "SOUMETTRE_ECHEC", erreurs: err.champs });
        }
    }

    return (
        <form onSubmit={gererSubmit}>
            <input
                value={etat.email}
                onChange={(e) => dispatch({ type: "CHANGER_CHAMP", champ: "email", valeur: e.target.value })}
            />
            <button disabled={etat.enEnvoi}>{etat.enEnvoi ? "Envoi..." : "Envoyer"}</button>
        </form>
    );
}
jsx
// useReducer + Context : un mini state manager "fait maison", sans dépendance externe
const PanierContext = createContext(null);

function PanierProvider({ children }) {
    const [etat, dispatch] = useReducer(panierReducer, etatInitial);
    return (
        <PanierContext.Provider value={{ etat, dispatch }}>
            {children}
        </PanierContext.Provider>
    );
}

function usePanier() {
    return useContext(PanierContext);
}

Résumé

  • Un reducer est une fonction pure (etat, action) => nouvelEtat, sans effet de bord ni mutation.
  • dispatch({ type, payload }) remplace plusieurs setState synchronisés à la main.
  • useReducer devient pertinent dès que les transitions de state impliquent plusieurs champs liés par des règles.
  • useReducer + Context forme un state manager local léger, sans bibliothèque tierce.

Exercices pratiques

1 disponible
1

Mission : sauver le panier qui ne se vide jamais visuellement

Objectif : Corriger un reducer qui mute l'état directement, et concevoir une nouvelle action de reducer respectant l'immuabilité.

Contexte

Sur ShoppingCart.jsx, cliquer sur "Vider le panier" affiche toujours les mêmes articles à l'écran, alors que console.log(etat) montre bien un tableau articles vide juste après. Le reducer contient : case "VIDER": etat.articles = []; return etat;. Un ticket demande d'ajouter une action APPLIQUER_CODE_PROMO qui doit stocker un pourcentage de réduction ET recalculer automatiquement les frais de livraison (gratuits au-dessus de 50€ après réduction).

Résoudre l’exercice →