Accueil / Articles / Le forage de propriétés n’est pas une raison d’installer Redux ou Zustand

Le forage de propriétés n’est pas une raison d’installer Redux ou Zustand

Tester quatre raisons courantes d’ajouter une bibliothèque d’État dans du code React fonctionnel : le contexte pour l’accès aux propriétés, useState, useSyncExternalStore, ainsi que le coût de rérendu du contexte.

2370 mots

Si vous demandez à une équipe pourquoi Redux, Zustand ou MobX sont utilisés dans leur application React, la réponse la plus fréquente est le problème du « prop drilling ». C’est certes agaçant, mais ce n’est pas une bonne raison d’ajouter une dépendance, tout comme les trois justifications qui suivent généralement. Ci-dessous, chacune de ces quatre raisons est testée à l’aide d’un petit exemple fonctionnel, en comparaison avec ce que React propose déjà pour ce cas, ainsi qu’avec les situations où une bibliothèque de gestion d’état s’avère réellement utile.

Quatre justifications courantes

Les arguments arrivent généralement dans un ordre prévisible :

  • Transmettre une valeur à travers des composants qui ne l’utilisent pas est problématique, donc l’application a besoin d’un store.
  • L’état côté client qui change réellement, comme le mode clair ou foncé, nécessite certainement une bibliothèque.
  • Pour que tous les composants qui lisent cet état se mettent à jour automatiquement, sans avoir à configurer manuellement les props, il faut impérativement une telle bibliothèque.
  • Si toute une bibliothèque est trop lourde, Context représente un compromis sûr.
  • Tous ces approches échouent dès que l’on examine du code concret.

    Prop drilling : un problème résolu par React il y a des années

    Le prop drilling consiste à transmettre une valeur à travers plusieurs niveaux uniquement pour permettre à un composant profondément imbriqué de la lire, sans que les niveaux intermédiaires ne l’utilisent. Quatre petits fichiers illustrent ce principe. App conserve un objet user dans son état et le transmet à Dashboard.

    // App.jsx
    import { useState } from 'react';
    import Dashboard from './components/Dashboard';
    
    function App() {
      const [user, setUser] = useState({ name: 'Akshat', avatarUrl: '/me.png' });
      return <Dashboard user={user} />;
    }
    export default App;
    

    Dashboard ne fait rien avec user si ce n’est le transmettre à Sidebar.

    // components/Dashboard.jsx
    import Sidebar from './Sidebar';
    
    function Dashboard({ user }) {
      return <Sidebar user={user} />;
    }
    export default Dashboard;
    

    Sidebar fait de même, en extrayant deux champs pour Avatar.

    // components/Sidebar.jsx
    import Avatar from './Avatar';
    
    function Sidebar({ user }) {
      return <Avatar name={user.name} avatarUrl={user.avatarUrl} />;
    }
    export default Sidebar;
    

    Seul Avatar affiche réellement les données.

    // components/Avatar.jsx
    function Avatar({ name, avatarUrl }) {
      return <img src={avatarUrl} alt={name} />;
    }
    
    export default Avatar;
    

    Ni Dashboard.jsx ni Sidebar.jsx ne consultent la variable user pour leur propre logique. Ce sont des intermédiaires. Si avatarUrl est renommé en photoUrl, les deux fichiers doivent être modifiés, même si leur comportement ne change pas, simplement parce qu’ils contiennent des données qui ne leur appartiennent pas.

    Le contexte fournit directement la valeur

    La solution intégrée de React est le contexte. Tout d’abord, un objet contextuel est créé une seule fois dans son propre module.

    // context/UserContext.js
    import { createContext } from 'react';
    
    const UserContext = createContext(null);
    export default UserContext;
    

    Ensuite, App enrobe l’arbre sous-jacent dans le Provider du contexte et transmet user en tant que valeur, au lieu de le passer comme propriété.

    // App.jsx
    import { useState } from 'react';
    import UserContext from './context/UserContext';
    import Dashboard from './components/Dashboard';
    
    function App() {
      const [user, setUser] = useState({ name: 'Akshat', avatarUrl: '/me.png' });
      return (
        <UserContext.Provider value={user}>
          <Dashboard />
        </UserContext.Provider>
      );
    }
    export default App;
    

    Dashboard et Sidebar se réduisent à un simple affichage, sans aucune mention de user.

    // components/Dashboard.jsx
    import Sidebar from './Sidebar';
    
    function Dashboard() {
      return <Sidebar />;
    }
    export default Dashboard;
    
    // components/Sidebar.jsx
    import Avatar from './Avatar';
    
    function Sidebar() {
      return <Avatar />;
    }
    export default Sidebar;
    

    Finalement, Avatar lit directement la valeur.

    // components/Avatar.jsx
    import { useContext } from 'react';
    import UserContext from '../context/UserContext';
    
    function Avatar() {
      const user = useContext(UserContext);
      return <img src={user.avatarUrl} alt={user.name} />;
    }
    export default Avatar;
    

    Chacun des trois éléments a une fonction précise. createContext crée un canal indépendant des props. Le Provider met user à disposition de tout son sous-arbre en même temps. useContext(UserContext) lit la valeur depuis le Provider le plus proche situé au-dessus du composant, en ignorant tous les niveaux intermédiaires. Les composants intermédiaires cessent de faire référence à user car ils n’en avaient jamais eu besoin. À titre informatif, React 19 permet également d’afficher directement l’objet contextuel en tant que provider, mais la forme .Provider présentée ici fonctionne toujours.

    Pourquoi l’argument du « prop drilling » a survécu à sa raison d’être

    L’histoire explique pourquoi ce débat persiste. Redux est apparu en juin 2015, créé par Dan Abramov et Andrew Clark, et s’inspire de l’architecture Flux de Facebook : un stock central unique avec des règles strictes régissant les modifications de l’état. L’API Context de React n’est devenue une fonctionnalité stable et officiellement prise en charge qu’à partir de la version 16.3, en mars 2018, soit près de trois ans plus tard. Au début du développement de Redux, un stock était effectivement le moyen pratique pour rendre une valeur accessible partout dans l’arborescence sans devoir la transmettre à travers chaque composant.

    Cela a cessé d’être vrai une fois que Context est devenu stable, mais l’enseignement n’a jamais suivi. Les tutoriels continuaient d’utiliser le problème lié au transfert des propriétés comme exemple motivant, car il est facile à illustrer au tableau blanc, et cette habitude a perduré bien après que la raison qui l’avait engendrée eut disparu.

    Cependant, la perforation de propriétés concerne la distribution, et non le changement. La question suivante est de savoir ce qui se passe lorsque la valeur distribuée commence à changer du côté du client.

    Changer d’état, c’est exactement ce que fait useState

    Prenons l’exemple d’un interrupteur de thème : une seule variable indiquant light ou dark, que l’on inverse à l’aide d’un bouton situé juste à côté du texte qui le montre.

    // components/SettingsPanel.jsx
    import { useState } from 'react';
    
    function SettingsPanel() {
      const [theme, setTheme] = useState('light');
      return (
        <div>
          <p>Current theme: {theme}</p>
          <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
            Toggle Theme
          </button>
        </div>
      );
    }
    export default SettingsPanel;
    

    En cliquant sur le bouton, on appelle setTheme ; SettingsPanel se rérend avec la nouvelle valeur, et le paragraphe est mis à jour. Aucune bibliothèque n’est impliquée, et aucune n’est nécessaire. Gérer une valeur, la modifier et rérendre l’élément qui la contient, c’est précisément à cela que sert useState, et chaque application React le fait constamment.

    L’exemple est généralement présenté avec une particularité qui effectue tout le travail réel. La difficulté ne réside pas dans le fait que la valeur change, mais plutôt dans le fait que cette valeur est lue ailleurs. Imaginez que le bouton reste à l’intérieur de SettingsPanel.jsx, tandis que Header.jsx et Sidebar.jsx, deux fichiers non liés qui ne sont ni importés par SettingsPanel ni n’importent ce dernier, doivent également afficher le thème actuel. L’état créé avec useState appartient à une seule instance de composant. Rien en dehors de ce composant ne peut le lire ou être averti des changements, à moins qu’un ancêtre commun ne le transmette, ce qui revient au problème du « prop drilling ».

    Ainsi, useState gère très bien les changements. La question restante est de savoir comment deux composants sans parent commun peuvent s’actualiser automatiquement sans props.

    Partager l’état entre des composants non liés avec useSyncExternalStore

    Si ni Header ni Sidebar ne peuvent posséder cette valeur, elle doit être stockée en dehors de l’arbre des composants : dans une variable au niveau du module qui est initialisée une seule fois, lors de l’importation initiale de son fichier, et non à l’intérieur d’une fonction de composant. React ne perçoit jamais une simple variable qui est réaffectée, ce qui l’empêche de re-renderiser quoi que ce soit automatiquement lorsque cette valeur change. Un petit module de stockage comble cette lacune.

    // store/themeStore.js
    import { useSyncExternalStore } from 'react';
    
    let state = { theme: 'light' };
    const listeners = new Set();
    export function setTheme(theme) {
      state = { ...state, theme };
      listeners.forEach((listener) => listener());
    }
    function subscribe(listener) {
      listeners.add(listener);
      return () => listeners.delete(listener);
    }
    export function useTheme() {
      return useSyncExternalStore(subscribe, () => state.theme);
    }
    

    Le module contient un objet state ainsi qu’un Set de souscripteurs. La fonction setTheme remplace l’objet state par un nouvel objet et appelle chaque souscripteur. Une fonction de registration privée ajoute un souscripteur au Set et renvoie une fonction de nettoyage permettant de le supprimer. La fonction useTheme relie tout ceci à React via useSyncExternalStore, un hook conçu spécifiquement pour gérer des états situés en dehors des composants et lus par plusieurs d’entre eux.

    Ce hook prend deux arguments. Le premier, cette fonction de registration, indique à React comment associer une fonction de rappel qui doit s’exécuter chaque fois que la valeur externe change, et elle doit retourner une fonction correspondante de désabonnement pour le nettoyage. Le second, getSnapshot, représenté ici par la fonction flèche () => state.theme, permet à React de récupérer la valeur actuelle chaque fois qu’il en a besoin.

    Deux consommateurs importent le hook, sans que l’un ne soit au courant de l’autre.

    // components/Header.jsx
    import { useTheme } from '../store/themeStore';
    
    function Header() {
      const theme = useTheme();
      return <header className={theme === 'dark' ? 'header-dark' : 'header-light'}>Site Header</header>;
    }
    export default Header;
    
    // components/Sidebar.jsx
    import { useTheme } from '../store/themeStore';
    
    function Sidebar() {
      const theme = useTheme();
      return <aside className={theme === 'dark' ? 'sidebar-dark' : 'sidebar-light'}>Navigation</aside>;
    }
    export default Sidebar;
    

    Un troisième composant contient le bouton et importe à la fois le hook ainsi que setTheme depuis le même store.

    // components/ThemeToggleButton.jsx
    import { setTheme, useTheme } from '../store/themeStore';
    
    function ThemeToggleButton() {
      const theme = useTheme();
      return (
        <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
          Toggle Theme
        </button>
      );
    }
    export default ThemeToggleButton;
    

    Que se passe-t-il, étape par étape

    Chaque étape ci-dessous est observable si vous exécutez le code :

    • Lors du montage, Header et Sidebar appellent chacun useTheme(). React appelle getSnapshot pour les deux, obtient la valeur 'light', et les rend avec cette valeur.
    • React invoque également la fonction de registration une fois par composant, ce qui ajoute deux rappels internes de React au tableau partagé listeners. Les composants restent des entrées indépendantes dans ce tableau.
  • Un clic sur ThemeToggleButton appelle setTheme('dark'). Tout d’abord, la variable state est réaffectée à un objet entièrement nouveau, { theme: 'dark' } ; il s’agit de code JavaScript classique sans rien de spécifique à React.
  • Ensuite, setTheme parcourt l’ensemble des écouteurs et les exécute un par un. Comme ces écouteurs appartiennent à React, leur exécution pousse React à examiner chaque composant qui s’est enregistré.
  • React appelle à nouveau getSnapshot pour le composant Header, constate que la valeur est 'dark' au lieu de 'light', et le rérendu à nouveau. La même vérification provoque un nouveau rendu pour Sidebar.
  • Ce setTheme n’est pas le setter retourné par useState. Il s’agit de code écrit manuellement qui effectue deux tâches en une seule appel : mettre à jour la source de vérité, puis informer tous les écouteurs enregistrés.

    Aucun paramètre n’a été transmis nulle part. Tout le câblage se compose d’importations. C’est aussi, à peu près, ce que fait Zustand en interne : une petite version emballée du même schéma de registre et d’notification.

    Points importants à connaître

    • getSnapshot doit retourner la même valeur lorsque rien n’a changé. Il est sûr de retourner une primitive comme state.theme ; créer un nouvel objet ou un nouveau tableau à chaque appel fait croire à React que le stock a constamment changé.
    • Si vous affichez du contenu sur le serveur, useSyncExternalStore accepte un troisième argument, getServerSnapshot, pour l’HTML initial. L’état au niveau du module sur le serveur est également partagé entre les requêtes, il convient donc d’y conserver uniquement des données par utilisateur.

    Pourquoi Context n’est pas le compromis sûr

    Avec un module store, il n’y a rien à fournir. Le state de themeStore.js n’a jamais fait partie de l’arbre des composants ; les composants y accèdent via une appel à un hook, sans aucun Provider ancêtre impliqué. Envelopper App dans un ThemeProvider ajouterait une couche qui ne transmettrait rien.

    Le contexte a ses propres utilisations légitimes : limiter l’accès à une valeur à un sous-arbre, ou remplacer une dépendance lors des tests en affichant un Provider différent. Il est cependant souvent recommandé comme alternative prudente à une bibliothèque, et dans ce rôle il coûte plus qu’il n’y paraît. Imaginez un seul contexte qui gérerait à la fois le thème et le panier d’achats.

    // context/AppContext.jsx
    import { createContext, useState } from 'react';
    
    const AppContext = createContext();
    export function AppProvider({ children }) {
      const [state, setState] = useState({
        theme: 'light',
        cart: ['book'],
      });
      return <AppContext.Provider value={state}>{children}</AppContext.Provider>;
    }
    export default AppContext;
    

    Une petite étiquette affiche uniquement le thème provenant de ce contexte.

    // components/ThemeLabel.jsx
    import { useContext } from 'react';
    import AppContext from '../context/AppContext';
    
    function ThemeLabel() {
      const { theme } = useContext(AppContext);
      return <span>{theme}</span>;
    }
    
    export default ThemeLabel;
    

    useContext(AppContext) abonne ThemeLabel à l’ensemble de l’objet géré par le Provider, et non uniquement à theme. Lorsque cart change ailleurs, le Provider reçoit un nouvel objet state, et comme la référence diffère, ThemeLabel est également réaffiché, même si theme n’a pas changé et que le composant ne touche jamais à cart. Le contexte ne dispose d’aucun mécanisme intégré pour indiquer « me réveiller uniquement lorsque ce champ change » ; chaque consommateur se déclenche à chaque modification de la valeur.

    Le magasin décrit dans la section précédente évite déjà ce problème. useTheme() lit une seule portion via son getSnapshot, et un composant se rérend uniquement lorsque la valeur retournée par cette portion change. Utiliser Context comme solution intermédiaire permet d’éviter une installation supplémentaire, mais entraîne un nombre plus élevé de rérendus. Vous pouvez atténuer cet effet en divisant l’état en plusieurs contextes plus restreints, mais dans ce cas vous créez manuellement ce que le magasin basé sur des sélecteurs vous offre gratuitement.

    Où les bibliothèques d’état gagnent réellement leur place

    Rien de tout cela ne rend Redux, Zustand ou MobX inutiles. Les quatre justifications avancées ont échoué, mais les bibliothèques elles-mêmes ne le sont pas. Le problème du « prop drilling » est résolu par Context. La modification de l’état est gérée par useState. Les mises à jour automatiques dans des composants non liés sont traitées par useSyncExternalStore, qui fait partie intégrante de React. Et Context, considéré comme un compromis, s’avère en réalité plus coûteux qu’un petit stockage pour des valeurs partagées et fréquemment modifiées.

    Le véritable intérêt d’une bibliothèque apparaît lorsque l’état partagé compte de nombreux écrivains indépendants plutôt que principalement des lecteurs, et lorsque le nombre de composants qui l’utilisent dépasse ce que quelques stockages modulaires personnalisés peuvent maintenir de cohérent. C’est à cette échelle que la coordination des mises à jour, les middleware, les outils de développement et le suivi prévisible des changements permettent à une bibliothèque mature de se justifier, et elle mérite donc un examen approfondi.

    Points clés

    • Préférez le Contexte plutôt qu’un store lorsque le seul problème est le transfert de valeurs à travers des couches qui ne les utilisent pas.
    • useState est l’outil idéal pour modifier l’état appartenant à un composant.
    • Pour un état partagé par des composants sans parent commun, un petit store construit à l’aide de useSyncExternalStore suffit souvent.
    • Un seul Contexte large force à re-render tous les consommateurs en cas de modification ; privilégiez des Contextes plus restreints ou un store basé sur des sélecteurs pour des données qui changent fréquemment.
    • Adoptez une bibliothèque de gestion d’état lorsque vous avez de nombreux écrivains indépendants et un nombre croissant de lecteurs, et non en raison du problème du « prop drilling ».

    Lectures complémentaires

  • Pourquoi push() rend votre UI React obsolète et comment les nouvelles références le résolvent — Comprendre pourquoi la modification des tableaux dans useState ne déclenche jamais un re-render, le fonctionnement de la vérification des références par React, et quand utiliser des copies étendues et des mises à jour fonctionnelles.