frontend / react
État complexe avec useReducer
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
useStateetuseReducerselon 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] }.
| Situation | Outil recommandé |
|---|---|
| Quelques valeurs indépendantes (ex: un booléen, un texte) | useState |
| Plusieurs champs liés par des règles métier communes | useReducer |
| 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.
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>
);
}// 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>
);
}// 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 plusieurssetStatesynchronisés à la main.useReducerdevient pertinent dès que les transitions de state impliquent plusieurs champs liés par des règles.useReducer+Contextforme un state manager local léger, sans bibliothèque tierce.
Exercices pratiques
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).