Accueil / Articles / Stocker les causes, déduire les conséquences : concevoir un état React minimal

Stocker les causes, déduire les conséquences : concevoir un état React minimal

Apprenez à repérer les états React redondants, remplacez les chaînes de synchronisation basées sur Effect et les flags booléens par des valeurs dérivées et des unions de statuts, puis décidez où l’état doit être stocké.

3592 mots

La plupart des composants React ne deviennent difficiles à modifier en raison d’une seule mauvaise décision. Ils y parviennent petit à petit, avec une appel useState qui semble raisonnable à chaque fois, jusqu’à ce que les mêmes informations soient stockées en trois endroits différents et que personne ne puisse déterminer quelle version est la bonne. Ce guide présente une liste de produits réaliste qui tombe dans ce piège, puis montre comment décider de ce qu’un composant doit réellement mémoriser, de ce qu’il doit calculer à chaque rendu, et où chacun des éléments restants d’état doit être placé. À la fin, vous disposerez d’une liste de contrôle concrète pour éliminer l’état avant qu’il ne provoque des erreurs de synchronisation.

Comment une simple liste de produits accumule l’état

Imaginez un écran d’administration interne qui liste les produits. Les utilisateurs peuvent taper un nom pour rechercher, limiter la liste à une seule catégorie, trier par prix, sélectionner un produit et voir combien de résultats restent. La première version ne conserve que ce que l’utilisateur a tapé et choisi :

function Products({ products }) {
  const [search, setSearch] = useState("");
  const [category, setCategory] = useState("all");

  // ...
}

Puis des demandes de fonctionnalités arrivent. La table doit afficher les produits correspondants, alors quelqu’un ajoute une variable d’état pour conserver la liste filtrée :

const [filteredProducts, setFilteredProducts] = useState(products);

Un compteur au-dessus de la table indique le nombre de produits correspondants, et ce chiffre dispose également de sa propre variable d’état :

const [resultCount, setResultCount] = useState(products.length);

Lorsqu’aucun résultat ne correspond, la page doit afficher un message d’état vide, donc une variable dédiée à cet effet est également ajoutée :

const [hasResults, setHasResults] = useState(true);

Vient ensuite le tri, qui implique à la fois la clé de tri choisie et une copie triée de la liste :

const [sortBy, setSortBy] = useState("name");
const [sortedProducts, setSortedProducts] = useState(products);

Chaque modification est petite et facile à approuver lors de l’examen. Cependant, quelques semaines plus tard, les rapports d’erreurs commencent à apparaître. Le changement de catégorie affiche parfois les bonnes lignes à côté du mauvais comptage. Le nettoyage de la zone de recherche fait apparaître temporairement le message « Aucun résultat ». Lorsqu’une nouvelle réponse API fournit de nouveaux products, le tableau continue d’afficher une liste filtrée obsolète jusqu’à ce que l’utilisateur clique sur quelque chose.

Le composant est plein d’états, mais il ne parvient plus à répondre à la seule question qui compte : lequel de ces valeurs est la bonne ?

useState n’est pas à blâmer. Le problème commence lorsque un composant conserve plusieurs versions stockées d’informations qui pourraient toutes être calculées à partir d’un ensemble plus restreint de faits de base. Chaque valeur supplémentaire stockée représente un élément de plus à mettre à jour en parallèle des autres, et c’est précisément là que les composants simples deviennent fragiles.

Chaque valeur stockée est une autre façon de se tromper

Les composants ont besoin d’un état parce que certaines informations doivent persister entre les mises à jour : le texte dans un champ de saisie, l’onglet actif, le fait qu’un modal soit ouvert, la ligne choisie par l’utilisateur. Ce sont des cas naturels où un état est utile.

L’erreur consiste à considérer que « cela s’affiche à l’écran » signifie nécessairement « cela doit être stocké ». Regardez à nouveau la page du produit avec toutes les valeurs conservées dans l’état :

const [search, setSearch] = useState("");
const [category, setCategory] = useState("all");
const [filteredProducts, setFilteredProducts] = useState(products);
const [resultCount, setResultCount] = useState(products.length);
const [hasResults, setHasResults] = useState(true);

Chaque variable porte un nom approprié, mais elles ne sont pas indépendantes les unes des autres. La liste filtrée dépend de trois entrées :

products + search + category

Le comptage dépend de la liste filtrée :

filteredProducts.length

Et le drapeau d’état vide dépend du comptage :

resultCount > 0

Quelques faits réels ont été développés en plusieurs conséquences stockées. Cela ouvre la voie à des combinaisons que l’interface ne devrait jamais pouvoir afficher, comme celle-ci :

filteredProducts = []
resultCount = 4
hasResults = true

React conservera volontiers ces valeurs. Vous avez déclaré trois éléments d’état indépendants, donc React les traite comme tels. Il revient entièrement à votre application de maintenir leur cohérence logique, et chaque gestionnaire d’événement qui les modifie doit se souvenir des autres.

La documentation React déconseille les états redondants et dupliqués pour cette raison précise : lorsque une valeur peut être calculée à partir des props ou d’autres états pendant le rendu, la stocker séparément ne crée que de nouvelles occasions pour que les copies soient en désaccord.

La leçon à retenir n’est pas « appeler useState moins souvent ». Il s’agit plutôt d’un changement dans la façon dont on considère ce qu’est un état :

Rappelez-vous les données que le composant ne peut pas récupérer d’aucune autre manière. Calculez tout ce qui en découle.

Gardez les entrées dans l’état et calculez le reste

La page du produit n’a jamais eu besoin de mémoriser resultCount. Ce qu’elle doit mémoriser, c’est ce que l’utilisateur a saisi dans la barre de recherche et quelle catégorie il a choisie. Ce sont des choix faits par une personne, et rien dans les données du produit ne peut les reconstituer. Tout le reste en découle.

function Products({ products }) {
  const [search, setSearch] = useState("");
  const [category, setCategory] = useState("all");

  const filteredProducts = products.filter((product) => {
    const matchesSearch = product.name
      .toLowerCase()
      .includes(search.toLowerCase());
    const matchesCategory =
      category === "all" || product.category === category;
    return matchesSearch && matchesCategory;
  });
  const resultCount = filteredProducts.length;
  const hasResults = resultCount > 0;
  // ...
}

Remarquez ce qui a disparu. Il n’est plus nécessaire de mettre à jour resultCount ; hasResults ne possède pas de méthode d’assignation, et il n’existe plus de cas où un gestionnaire met à jour la liste filtrée tout en oubliant le comptage. Chaque affichage se contente de recalculer les résultats à partir des données actuelles. Une nouvelle chaîne de recherche génère une nouvelle liste, une nouvelle catégorie produit également une nouvelle liste, et si le parent transmet un tableau products différent, le calcul utilise simplement celui-ci.

Le composant dispose désormais de moins de variables modifiables, ce qui représente une amélioration bien plus significative que la simple réduction du nombre de lignes de code. Une valeur dérivée peut toujours contenir une erreur logique, mais elle ne peut jamais devenir obsolète parce qu’un gestionnaire aurait oublié de la mettre à jour. Cela élimine toute une série d’états auxquels le composant pouvait auparavant accéder.

La documentation de React illustre la même idée avec une fullName composée du prénom et du nom : si vous pouvez la calculer lors du rendu, une variable d’état distincte ne sert à rien d’autre qu’à créer des incohérences.

Un test rapide que vous pouvez appliquer lors d’une revue de code :

If I deleted this state variable,
could I reconstruct its value exactly
from current props and other state?

Si la réponse est oui, commencez par en faire un simple calcul. L’état existe pour stocker des informations, et non pour mettre en cache chaque résultat intermédiaire produit par le composant au fil du processus.

Lorsque useEffect devient un pipeline de synchronisation

Une réaction courante face à un état dérivé obsolète est d’utiliser useEffect pour mettre à jour automatiquement la copie. Le composant produit alors quelque chose comme ceci :

const [filteredProducts, setFilteredProducts] = useState(products);

useEffect(() => {
  const nextProducts = products.filter((product) => {
    const matchesSearch = product.name
      .toLowerCase()
      .includes(search.toLowerCase());
    const matchesCategory =
      category === "all" || product.category === category;
    return matchesSearch && matchesCategory;
  });
  setFilteredProducts(nextProducts);
}, [products, search, category]);

Ensuite, un deuxième effet maintient le compteur en accord avec la liste :

useEffect(() => {
  setResultCount(filteredProducts.length);
}, [filteredProducts]);

Et peut-être un troisième effet gère le drapeau d’état vide :

useEffect(() => {
  setHasResults(resultCount > 0);
}, [resultCount]);

Ils forment ensemble un petit pipeline interne :

products/search/category
        ↓
filteredProducts
        ↓
resultCount
        ↓
hasResults

Aucune de ces étapes ne communique avec quoi que ce soit en dehors de React. Elles se contentent de transformer des valeurs déjà détenues par React, et c’est cette distinction qui est au cœur de la question. La documentation de React considère les Effects comme un moyen d’assurer que le composant reste en phase avec des éléments que React ne contrôle pas, tels qu’une API du navigateur, un socket ou un widget non React. Lorsqu’un Effect existe uniquement pour mettre à jour une partie de l’état du composant en réponse à une autre, la recommandation actuelle est de se demander si cette deuxième partie d’état devrait vraiment exister.

Il existe également un coût en temps de exécution facile à négliger. Chaque effet s’exécute après que React ait déjà effectué le rendu, de sorte que chaque maillon de la chaîne déclenche un autre rendu avec des valeurs partiellement mises à jour. C’est précisément d’où provient l’apparition éphémère de « Aucun résultat » dans le scénario initial : lors d’un rendu, la nouvelle liste existe déjà, mais le flag reflète encore le comptage ancien.

La version calculée ne comporte absolument pas de chaîne :

const filteredProducts = filterProducts(
  products,
  search,
  category
);

const resultCount = filteredProducts.length;
const hasResults = resultCount > 0;

Ce n’est pas seulement une syntaxe plus propre : cela change la façon dont on doit réfléchir. Avec un état dérivé stocké, on suit la date de dernière modification de chaque valeur, on vérifie si l’Effect correspondant a déjà été exécuté, si son tableau de dépendances est complet, et s’il y a d’autres mises à jour en attente derrière lui. Avec un calcul, on se concentre sur les entrées et les sorties, ce qui constitue un modèle bien plus facile à maintenir pour les transformations pures. Si votre base de code contient déjà des Effects de ce type, la refonte étape par étape présentée dans Arrêter la synchronisation de l’état avec useEffect explique comment les supprimer en toute sécurité.

Remplacer les flags booléens par un seul statut

Les valeurs dupliquées représentent une forme d’état superflu. Un autre cas se produit lorsque un même concept est réparti sur plusieurs booléens indépendants. La soumission d’un formulaire en est un exemple classique :

const [isIdle, setIsIdle] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [isError, setIsError] = useState(false);

Le flux doit se trouver exactement dans l’une des quatre phases :

idle
submitting
success
error

Cependant, quatre booléens peuvent exprimer seize combinaisons, dont beaucoup sont sans sens. Le formulaire peut prétendre être soumis et déjà réussi en même temps :

isSubmitting = true
isSuccess = true

Il peut également signaler à la fois le succès et l’échec :

isSuccess = true
isError = true

Chaque indicateur peut être false, ce qui ne correspond à aucune des phases. L’interface utilisateur n’émettra probablement jamais délibérément de telles combinaisons, mais la structure de données les permet ; ainsi, une omission dans l’appel d’un gestionnaire suffit pour y parvenir. Les directives de React concernant la structuration de l’état recommandent explicitement d’éviter de telles contradictions et de réduire le nombre de variables permettant l’expression d’états d’interface impossibles.

Une seule valeur de statut décrit ce concept avec bien plus de précision. En TypeScript, une union de littéraux de chaîne permet également au compilateur de rejeter les fautes d’orthographe et les phases inconnues :

type Status =
  | "idle"
  | "submitting"
  | "success"
  | "error";

const [status, setStatus] = useState<Status>("idle");

Les booléens pratiques restent disponibles, mais sous forme de valeurs dérivées cette fois :

const isSubmitting = status === "submitting";
const isSuccess = status === "success";
const isError = status === "error";

La différence semble mineure, mais elle est fondamentale. La première version exige que votre code maintienne quatre éléments en cohérence. La seconde stocke un seul élément et lit quatre interprétations de celui-ci.

Les avantages augmentent avec la complexité du composant. Un processus de paiement peut par exemple passer par ces phases :

editing
validating
submitting
confirmed
failed

Un importeur de fichiers peut également passer par celles-ci :

idle
uploading
processing
completed
failed

Lorsqu’une composante possède des modes qui s’excluent mutuellement, faites de cette exclusion une partie du modèle d’état plutôt que d’une règle que chaque gestionnaire doit respecter. C’est vous qui décidez des états que le programme peut représenter, et cette décision mérite autant d’attention que le markup lui-même. Une précaution : si une phase contient des données, comme un message d’erreur qui n’existe que dans la phase failed, une union discriminée d’objets permet de conserver ces données associées à la bonne phase au lieu d’ajouter une autre variable non structurée.

Avoir moins de variables ne revient pas à utiliser un seul grand objet

Dès qu’une équipe entend l’expression « réduire l’état », une correction excessive et tentante consiste à tout mettre dans un seul objet :

const [state, setState] = useState({
  search: "",
  category: "all",
  selectedProductId: null,
  sidebarOpen: false,
  page: 1,
});

Cela ne constitue pas une amélioration par défaut. Les appels distincts à useState n’entraînent aucun coût significatif, donc minimiser leur nombre n’est pas un objectif. L’objectif est de représenter clairement des informations indépendantes et d’éviter de stocker deux fois la même valeur.

search et category changent selon leurs propres horaires, tandis que sidebarOpen n’a aucun rapport avec l’un ou l’autre. Les conserver en tant que variables distinctes rend chaque mise à jour évidente au point d’appel :

const [search, setSearch] = useState("");
const [category, setCategory] = useState("all");
const [selectedProductId, setSelectedProductId] =
  useState<string | null>(null);
const [sidebarOpen, setSidebarOpen] = useState(false);

Les documents React adoptent la même logique. Les valeurs qui changent toujours ensemble peuvent justifier un regroupement, tandis que les données redondantes, contradictoires, dupliquées ou profondément imbriquées doivent être réduites. Fusionner des valeurs non liées présente également un inconvénient pratique : chaque mise à jour doit répéter l’objet précédent, et oublier de le faire efface silencieusement les autres champs.

La question pertinente n’est donc pas de savoir si un ensemble de valeurs peut tenir dans un seul objet. Presque tout est possible. Demandez plutôt :

Ces valeurs forment-elles un état cohérent dont les transitions sont liées entre elles ?

Lorsque c’est le cas, les regrouper peut rendre le code plus clair. Lorsque ce n’est pas le cas, un objet combiné ne fait qu’obscurcir la manière dont chaque mise à jour affecte les données. Réduire l’état consiste à éliminer les informations stockées deux fois, et non à compresser le composant au maximum d’hooks possibles.

Dériver des valeurs sans négliger les performances

Retirer une liste filtrée de l’état suscite généralement une objection : le filtre s’exécute-t-il alors à chaque rendu ? En effet, et pour la plupart des transformations courantes, c’est précisément l’équilibre souhaité. Filtrer un tableau de taille modérée est peu coûteux, et le faire en ligne maintient la simplicité du composant sans aucun coût notable.

Si l’analyse montre qu’une transformation est réellement coûteuse, par exemple une grande liste qui doit être filtrée et triée, vous pouvez mettre en cache le résultat à l’aide de useMemo:

const filteredProducts = useMemo(() => {
  return products
    .filter((product) => {
      const matchesSearch = product.name
        .toLowerCase()
        .includes(search.toLowerCase());

       const matchesCategory =
        category === "all" ||
        product.category === category;
      return matchesSearch && matchesCategory;
    })
    .sort(compareProducts);
}, [products, search, category, sortBy]);

Faites attention à ce que useMemo ne modifie pas. filteredProducts n’est plus devenu un état écrivable. Il s’agit toujours d’une fonction pure à partir de ses entrées ; la mémorisation ne décide que si React peut réutiliser le résultat précédent lors d’un rendu donné au lieu de le recalculer. Cela permet de conserver la correction et l’optimisation comme deux aspects distincts. La documentation React présente useMemo strictement comme une optimisation des performances et met en garde contre la dépendance à cet outil pour assurer un comportement correct, car React peut éliminer les valeurs mémorisées.

L’ordre des opérations qui en résulte est le suivant :

First make the state model correct.

Then measure.

Then optimize expensive calculations if necessary.

Utiliser l’état comme un cache manuel inverse cet ordre. Cela ajoute une complexité de synchronisation dès le départ, avant même que l’on ait pu démontrer que le calcul est lent. Il convient également de vérifier qu’un calcul mémorisé liste tous les paramètres d’entrée dans son tableau de dépendances ; dans l’exemple ci-dessus, sortBy y est listé car on s’attend à ce que le comparateur de tri dépende de lui.

Mettre chaque élément d’état là où la décision est partagée

Même l’état qui a réellement besoin d’exister peut poser des problèmes s’il se trouve dans le mauvais composant. Supposons que chaque ligne de produit suive sa propre sélection :

function ProductRow({ product }) {
  const [selected, setSelected] = useState(false);

  // ...
}

Cela fonctionne tant que chaque ligne peut être activée ou désactivée indépendamment. Mais les exigences changent : un seul produit peut être sélectionné à la fois. Soudain, plusieurs composants frères conservent chacun leur propre copie de ce qui devrait être une information partagée unique, à savoir quel produit est sélectionné. Lorsque cette information concerne plusieurs composants frères, leur parent doit en être le détenteur :

function ProductTable({ products }) {
  const [selectedProductId, setSelectedProductId] =
    useState<string | null>(null);

   return products.map((product) => (
    <ProductRow
      key={product.id}
      product={product}
      selected={product.id === selectedProductId}
      onSelect={() => setSelectedProductId(product.id)}
    />
  ));
}

Les lignes ne stockent plus du tout l’état de sélection. Elles reçoivent un booléen et une fonction de rappel, et il n’y a qu’une seule source de vérité :

selectedProductId

Les documents React présentent cela comme l’attribution à chaque élément d’état distinct d’un seul composant propriétaire. Lorsque plusieurs composants doivent coordonner leurs actions autour de la même information, en la faisant remonter au parent partagé le plus proche, on évite que les copies ne divergent.

Rien de tout cela ne justifie le fait de placer tout ce contenu à la racine de l’application. Il convient de préciser que tout le reste doit rester localisé. Le fait qu’une aide contextuelle soit visible n’a rien à voir avec des données d’authentification, et un champ de formulaire partiellement saisi justifie rarement l’utilisation d’un stockage global. Gérer l’état est plus simple lorsque son propriétaire correspond au degré de partage de la décision sous-jacente. Si l’état est placé trop bas, les composants dupliquent les informations ; s’il est placé trop haut, des parties éloignées de l’application se rérendent pour des modifications qui ne les concernent pas. Trouver cette limite fait partie intégrante d’un bon design de l’état, et Rethinking React State: Where Your Data Should Actually Live explore plus en détail les options locales, partagées, serveur et basées sur des URL.

Les réducteurs organisent les transitions, pas le modèle

Lorsqu’un composant contient de nombreux fonctions de mise à jour, passer à useReducer est une étape courante et souvent judicieuse. Au lieu d’un gestionnaire qui effectue plusieurs appels coordonnés comme ceci :

setStatus("submitting");
setError(null);
setLastAttempt(Date.now());

on décrit ce qui s’est produit comme un seul événement :

dispatch({ type: "submitted" });

Un réducteur rassemble les transitions en un seul endroit, ce qui est avantageux lorsque plusieurs valeurs étroitement liées changent en même temps. Ce qu’il ne peut pas faire, c’est empêcher les données redondantes de le rester. Cet état initial reste problématique :

const initialState = {
  search: "",
  products: [],
  filteredProducts: [],
  resultCount: 0,
  hasResults: true,
};

En regroupant des valeurs dupliquées dans un réducteur, le problème de synchronisation reste inchangé ; on se contente simplement de déplacer la logique de synchronisation dans le réducteur. Chaque action pourrait mettre à jour correctement toutes les copies aujourd’hui, mais le modèle permet toujours l’existence de plusieurs versions stockées de la même information, et la prochaine action ajoutée pourrait en manquer une.

const initialState = {
  search: "",
  category: "all",
  sortBy: "name",
};

La liste des produits visible est ensuite générée lors du rendu à partir de l’état du réducteur ainsi que des products actuels. useReducer est un outil utile lorsque les transitions deviennent complexes, mais il ne remplace pas la question fondamentale de savoir ce que le composant doit réellement mémoriser. Répondez d’abord à cette question, puis choisissez l’outil qui permettra de gérer cela.

Une liste de contrôle pour examiner l’état du composant

useState rend l’ajout d’état presque sans friction, et cette facilité cache le coût architectural. Chaque nouvelle variable représente une valeur qui peut changer indépendamment. Si elle duplique quelque chose déjà disponible, il devient alors nécessaire d’établir des règles pour maintenir les deux versions en cohérence. Une duplication est gérable, mais cinq entraînent un enchevêtrement de Effects et de listes de dépendances, des fonctions de mise à jour qui déclenchent d’autres fonctions de mise à jour, du code de réinitialisation, des lectures obsolètes, des flags en conflit, ainsi que des bugs qui ne se manifestent qu’après une séquence précise de clics. La solution n’est rarement un mécanisme de synchronisation plus intelligent ; généralement, la synchronisation ne devrait même pas exister au départ.

Lorsque l’état d’un composant continue de croître, examinez chaque valeur stockée et demandez-vous :

  • Représente-t-elle une décision prise par l’utilisateur ou par le système ?
  • Le composant a-t-il besoin de s’en souvenir entre les rendus ?
  • Pouvez-vous le reconstruire précisément à partir des props actuels ou du reste de l’état ?
  • Existe-t-il une séquence d’événements dans laquelle il diffère d’une autre valeur stockée ?
  • Un autre composant conserve-t-il une copie du même fait ?
  • Fait-il partie du composant dont le sous-arbre partage réellement cette décision ?
  • Ces questions vous en disent bien plus qu’un simple décompte des Hooks. Un composant contenant huit éléments d’état indépendants et nécessaires peut être parfaitement bien conçu, tandis qu’un composant avec trois variables possède déjà trop d’éléments si deux d’entre elles ne sont que des copies ou des conséquences de la troisième.

    Points clés

    • Stockez les causes telles que les entrées utilisateur et les sélectionnements ; dérivez les conséquences comme les listes filtrées, les comptages et les indicateurs pendant le rendu.
    • Un Effect qui ne modifie l’état que à partir d’un autre état indique que cette deuxième valeur devrait être le résultat d’un calcul.
    • Modélisez les modes mutuellement exclusifs comme une seule valeur d’état afin que des combinaisons impossibles ne puissent pas être représentées.
    • Groupez les valeurs uniquement lorsqu’elles changent ensemble ; un grand objet n’est pas une finalité en soi.
    • Utilisez useMemo après avoir effectué des mesures, et rappelez-vous qu’il s’agit d’un cache, pas d’une source de vérité.
    • Attribuez un seul propriétaire aux faits partagés au niveau du parent commun le plus bas, et conservez l’état de l’interface utilisateur purement local.
    • Lorsqu’un composant devient difficile à modifier, demandez quel fait du monde réel chaque variable d’état représente avant d’ajouter un autre setteur. L’état le plus facile à synchroniser est celui que vous n’avez jamais stocké.

    Lectures complémentaires

  • Comment les décisions minimes se compotent dans des codebases React de longue durée — Quinze habitudes de maintenance pour les applications React qui fonctionnent pendant des années : code lisible, composants ciblés, état limité aux besoins, dépendances réduites, tests et surveillance.
  • Choisir le bon outil React : Derive, Handle, Fetch, Defer ou Effect — Un guide de décision pour remplacer les appels reflexifs de useEffect par des valeurs dérivées, des gestionnaires d’événements, une couche de données, useTransition, useMemo mesuré et les API de React 19.
  • Concevoir pour les demandes échouées dans Angular : états, intercepteurs et reprises — Apprenez à classifier les échecs HTTP dans Angular, à gérer correctement l’état de chargement, à centraliser le traitement via des intercepteurs, à effectuer des reprises en toute sécurité et à afficher des messages sur lesquels les utilisateurs peuvent agir.
  • Une liste de vérification pour l’examen du code React : état dérivé, reducers, effects et memo — Apprenez à repérer sept problèmes courants lors de l’examen du code React, allant de l’état dupliqué et des requêtes manuelles à l’utilisation incorrecte des effects et à la memoïsation excessive, ainsi que ce qu’il convient d’écrire à la place.