Kodokon kodokon.com

Patterns experts : compound components, inversion de contrôle

Concevez des API de composants flexibles grâce aux compound components, au state reducer et au bon choix entre render props et hooks.

10 min · 3 questions

Ouvrir cette leçon dans Kodokon

Un composant <Tabs items={...} labels={...} icons={...} /> finit toujours par crouler sous les props de configuration. Le pattern des compound components renverse l'approche : plusieurs composants collaborent et partagent un état implicite via un contexte, tandis que l'utilisateur garde la main sur la composition JSX (ordre, imbrication, éléments intercalés). C'est le modèle de <select> et <option> en HTML, et celui de bibliothèques comme Radix ou Headless UI.

JSX
const TabsContext = createContext(null);

function Tabs({ defaultTab, children }) {
  const [active, setActive] = useState(defaultTab);
  const value = useMemo(
    () => ({ active, setActive }),
    [active]
  );
  return (
    <TabsContext.Provider value={value}>
      {children}
    </TabsContext.Provider>
  );
}
Le parent expose l'état partagé via un contexte mémoïsé.

Chaque sous-composant lit le contexte et n'a besoin d'aucune prop de câblage : l'utilisateur compose librement, le pattern garantit la cohérence. Notez la valeur de contexte enveloppée dans useMemo : sans cela, chaque rendu du parent créerait un nouvel objet et forcerait le rendu de tous les consommateurs.

JSX
function Tab({ id, children }) {
  const ctx = useContext(TabsContext);
  return (
    <button
      aria-selected={ctx.active === id}
      onClick={() => ctx.setActive(id)}
    >
      {children}
    </button>
  );
}

// <Tabs defaultTab="a">
//   <Tab id="a">Profil</Tab>
//   <Tab id="b">Reglages</Tab>
// </Tabs>
Aucune prop de câblage : le contexte fait le lien.

Deuxième pattern : l'inversion de contrôle, dont la forme la plus aboutie est le state reducer popularisé par Kent C. Dodds dans Downshift. Plutôt que d'ajouter une prop booléenne à chaque nouveau besoin, le hook expose ses transitions d'état : l'appelant fournit un reducer qui reçoit chaque action et peut la laisser passer, la modifier ou l'annuler. La bibliothèque garde son comportement par défaut ; le contrôle fin appartient au consommateur.

JSX
function defaultReducer(state, action) {
  if (action.type === 'toggle') {
    return { on: !state.on };
  }
  return state;
}

function useToggle({ reducer = defaultReducer } = {}) {
  const [state, dispatch] = useReducer(reducer, {
    on: false,
  });
  const toggle = () => dispatch({ type: 'toggle' });
  return { on: state.on, toggle };
}
L'appelant peut intercepter chaque transition d'état.

Enfin, l'éternel débat render props contre hooks. Les hooks ont gagné pour le partage de logique : pas de fausse hiérarchie dans l'arbre, pas de pyramide de wrappers, composition naturelle de plusieurs sources. Mais la render prop conserve un territoire légitime : quand la variation porte sur le rendu lui-même. Une liste virtualisée qui vous demande comment dessiner chaque ligne, un composant headless qui délègue tout son JSX : là, une fonction de rendu passée en prop reste l'outil le plus direct, car un hook ne peut pas décider insérer du JSX dans l'arbre de l'appelant.

JSX
function MouseTracker({ render }) {
  const [pos, setPos] = useState({ x: 0, y: 0 });
  const onMove = (e) =>
    setPos({ x: e.clientX, y: e.clientY });
  return (
    <div onMouseMove={onMove}>{render(pos)}</div>
  );
}

// Pour la logique seule, un hook suffit :
// const pos = useMousePosition();
Render prop pour déléguer le rendu, hook pour la logique.

Quiz de validation

Vérifiez que vous avez bien retenu les points clés de cette leçon.

  1. Quel mécanisme permet aux compound components de partager leur état sans props explicites ?
    • Une variable de module partagée
    • Un contexte React fourni par le composant parent
    • La duplication de l'état dans chaque enfant
    • Les refs transmises par cloneElement
  2. Qu'inverse exactement le pattern state reducer ?
    • Le sens des données entre parent et enfant
    • Le contrôle des transitions d'état, confié à l'appelant
    • L'ordre d'exécution des hooks
    • La responsabilité du rendu du fallback
  3. Dans quel cas une render prop reste-t-elle préférable à un hook ?
    • Quand on veut partager de la logique entre composants
    • Quand l'appelant doit fournir le JSX à insérer dans l'arbre du composant
    • Quand il faut mémoïser un calcul coûteux
    • Quand le composant n'a pas d'état