Cours JavaScript et React · Module 15: Patterns et architecture

Inversion of Control en React - Des API de composants flexibles

10 min de lecture
Dans cette leçon8

Le Commandant Nova veut une carte de mission à trois endroits : sur la passerelle avec des boutons d'action, dans les archives sans boutons mais avec un pied de carte, et dans le rapport avec un lien vers les détails. La première version de la carte reçoit donc de plus en plus de drapeaux booléens et de plus en plus de conditions à l'intérieur :

1// Chaque nouvel écran ajoute un drapeau et une condition de plus
2function MissionCard({ mission, showActions, showFooter, compact }) {
3  return (
4    <div className={compact ? 'card card-compact' : 'card'}>
5      <h3>{mission.name}</h3>
6      {showActions && <button>Annuler</button>}
7      {showFooter && <span>Priorité : {mission.priority}/10</span>}
8    </div>
9  );
10}

La carte décide de tout elle-même, donc chaque idée d'un nouvel écran oblige à modifier son code. Un mois plus tard, il y a une douzaine de drapeaux et personne n'ose plus y toucher. Sur un vaisseau, ce problème a été réglé depuis longtemps : un module possède une prise standard, et c'est l'ingénieur qui décide d'y brancher un moteur à plasma ou un propulseur ionique. En React, ce principe s'appelle l'Inversion of Control (IoC), l'inversion du contrôle.

Qu'est-ce que l'Inversion of Control ?

L'Inversion of Control consiste, pour un composant, à confier au consommateur, c'est-à-dire au composant qui l'affiche, le contrôle de son comportement et de son apparence. Au lieu de prévoir chaque cas avec un drapeau, il laisse des emplacements que le consommateur remplit lui-même : des props, des slots, des render props et ses propres règles d'état. Tu connais déjà une partie de ces outils grâce à la leçon sur la composition. On va maintenant en faire une API flexible, puis, à la fin, les classer du moins au plus de contrôle cédé.

Slot Pattern - les slots nommés

Dans la leçon sur la composition, la carte avait un titre et des children. Elle reçoit maintenant trois slots nommés : header, actions et footer. Chacun d'eux est une prop ordinaire dans laquelle tu passes un élément JSX tout prêt :

1function SpaceCard({ header, footer, actions, children }) {
2  return (
3    <div className="space-card">
4      {header && <div className="card-header">{header}</div>}
5      <div className="card-body">{children}</div>
6      {actions && <div className="card-actions">{actions}</div>}
7      {footer && <div className="card-footer">{footer}</div>}
8    </div>
9  );
10}

L'écriture {header && ...} n'affiche le cadre d'un slot que si le consommateur y a passé quelque chose, donc une carte sans pied de carte ne laisse pas de barre vide. children fonctionne exactement comme avant. Voici comment la MissionCard de la passerelle, reconstruite, remplit les slots :

1function MissionCard({ mission }) {
2  return (
3    <SpaceCard
4      header={<h3>{mission.name}</h3>}
5      actions={
6        <>
7          <button>Détails</button>
8          <button>Annuler</button>
9        </>
10      }
11      footer={<span>Priorité : {mission.priority}/10</span>}
12    >
13      <p>Distance : {mission.distance} al</p>
14    </SpaceCard>
15  );
16}

Un slot peut accueillir plusieurs éléments : le fragment <>...</> regroupe les deux boutons en une seule valeur. Les archives utiliseront la même carte sans actions, et SpaceCard ne recevra pas un seul nouveau drapeau.

Composant polymorphe - la prop as

Le bouton d'action et le lien vers le rapport doivent se ressembler, mais en HTML ce sont des éléments différents : un <button> exécute une action, et un <a> mène à une adresse. Au lieu d'écrire deux composants avec les mêmes styles, laisse le consommateur choisir l'élément grâce à la prop as :

1function SpaceButton({ as: Component = 'button', children, ...props }) {
2  return <Component {...props}>{children}</Component>;
3}

L'écriture as: Component = 'button' lit la prop as, la nomme Component et lui donne la valeur par défaut 'button'. La majuscule est indispensable : JSX traite les noms en minuscules comme des balises HTML, donc écrire <as> ne fonctionnerait pas. Les autres props, comme href ou onClick, arrivent dans props et sont transmises telles quelles. La version complète ajoute une variante et calcule les classes dans une variable cls :

1function SpaceButton({ as: Component = 'button', variant = 'primary', children, ...props }) {
2  const cls = `space-btn btn-${variant}`;
3  return (
4    <Component className={cls} {...props}>
5      {children}
6    </Component>
7  );
8}

{...props} vient après className, donc le consommateur peut même remplacer la classe si besoin. En React 19, ref est lui aussi une prop ordinaire : il arrive donc jusqu'à l'élément avec le reste, sans forwardRef. Reste l'utilisation. Link de la bibliothèque React Router reçoit l'adresse dans la prop to, et non href :

1function MissionToolbar({ onStart }) {
2  return (
3    <nav>
4      <SpaceButton onClick={onStart}>Lancer</SpaceButton>
5      <SpaceButton as="a" href="/reports">Rapports</SpaceButton>
6      <SpaceButton as={Link} to="/missions" variant="danger">Liste des missions</SpaceButton>
7    </nav>
8  );
9}

Le premier affiche un <button>, le deuxième un <a href>, et le troisième le composant Link, qui affiche lui-même un lien. Les trois reçoivent leurs classes d'un seul endroit, et SpaceButton ne sait pas ce qu'il deviendra. Utilise as pour choisir la bonne sémantique, pas pour la contourner : <SpaceButton as="div"> ressemble à un bouton, mais il ne reçoit pas le focus et ne réagit pas à Entrée.

Injection de composant - renderItem

La liste des missions de la passerelle n'affiche que les noms, alors que l'écran des missions montre des cartes complètes. Si MissionList importait une carte précise, elle serait liée à une seule apparence. À la place, le consommateur injecte la façon d'afficher un élément : la fonction renderItem, c'est-à-dire la render prop que tu connais depuis la leçon sur la composition :

1function MissionList({ missions, renderItem, emptyState }) {
2  if (missions.length === 0) {
3    return emptyState || <p>Aucune mission</p>;
4  }
5  return (
6    <ul className="mission-list">
7      {missions.map((mission, index) => (
8        <li key={mission.id}>{renderItem(mission, index)}</li>
9      ))}
10    </ul>
11  );
12}

La liste continue de gérer ce qui est commun : la balise <ul>, la key et l'état vide. C'est le consommateur qui décide du contenu d'un élément, et le second argument, index, lui permet par exemple de numéroter les missions. Quand la liste est vide, MissionList affiche le slot emptyState ou un texte par défaut. Deux écrans, la même liste :

1function BridgeScreen({ missions }) {
2  return (
3    <MissionList
4      missions={missions}
5      renderItem={(mission, index) => <span>{index + 1}. {mission.name}</span>}
6    />
7  );
8}
9
10function MissionsScreen({ missions }) {
11  return (
12    <MissionList
13      missions={missions}
14      renderItem={(mission) => <MissionCard mission={mission} />}
15      emptyState={<p>La flotte attend les ordres.</p>}
16    />
17  );
18}

Le code de MissionList n'a pas changé d'un caractère, et pourtant il affiche tantôt des noms numérotés, tantôt des cartes complètes avec des slots. Le terme component injection couvre aussi le fait de passer le composant lui-même : as={Link} de la section précédente est justement un composant injecté.

Props collection et prop getters

Dans la leçon précédente, getToggleProps() renvoyait un ensemble d'attributs tout prêt. Cette idée existe en deux variantes. Une props collection est un simple objet de props que le consommateur étale sur son élément. Un prop getter est une fonction qui renvoie un tel objet, mais qui reçoit d'abord les props du consommateur et les fusionne avec les siennes. Un useToggle simplifié montre les deux variantes côte à côte :

1function useToggle(initialState = false) {
2  const [isOpen, setIsOpen] = useState(initialState);
3  const toggle = () => setIsOpen((prev) => !prev);
4
5  // props collection : un objet tout prêt
6  const toggleProps = { onClick: toggle, 'aria-expanded': isOpen };
7
8  // prop getter : une fonction qui fusionne les props du consommateur avec les nôtres
9  const getToggleProps = ({ onClick, ...props } = {}) => ({
10    ...props,
11    'aria-expanded': isOpen,
12    onClick: (event) => {
13      onClick?.(event);
14      toggle();
15    },
16  });
17
18  return { isOpen, toggleProps, getToggleProps };
19}

La différence apparaît quand le consommateur veut ajouter son propre onClick, par exemple une entrée dans le journal de bord. Avec <button {...toggleProps} onClick={log}>, sa fonction remplace la nôtre et le panneau ne s'ouvre plus. Le prop getter appelle les deux : d'abord la fonction du consommateur, s'il en a passé une (d'où onClick?.(event)), puis toggle. Les autres props, comme className, passent sans changement :

1function MissionReport({ onLog }) {
2  const { isOpen, getToggleProps } = useToggle();
3
4  return (
5    <section>
6      <button {...getToggleProps({ onClick: () => onLog('Rapport basculé') })}>
7        Rapport de mission
8      </button>
9      {isOpen && <p>Tous les systèmes fonctionnent.</p>}
10    </section>
11  );
12}

Des bibliothèques comme Downshift utilisent des prop getters, et c'est Kent C. Dodds qui a popularisé les noms de ces deux patterns. C'est pourquoi je te conseille de renvoyer des prop getters plutôt que des objets tout prêts : ils coûtent quelques lignes de plus, et le consommateur ne peut pas casser le fonctionnement ni l'accessibilité en ajoutant son propre onClick.

State Reducer - le consommateur change les règles

Le State Reducer va le plus loin. Le composant a une logique d'état par défaut, mais le consommateur peut remplacer les règles de transition sans copier le code du composant. Prenons le sas du vaisseau. Par défaut, il réagit à deux actions, OPEN et CLOSE, décrites par un reducer, une fonction qui calcule le nouvel état à partir de l'état et d'une action :

1function airlockReducer(isOpen, action) {
2  switch (action.type) {
3    case 'OPEN': return true;
4    case 'CLOSE': return false;
5    default: return isOpen;
6  }
7}

Le hook reçoit le reducer dans ses options et, si le consommateur n'en passe pas, il utilise celui par défaut. useReducer gère l'état, et dispatch envoie une action au reducer :

1function useAirlock({ reducer = airlockReducer } = {}) {
2  const [isOpen, dispatch] = useReducer(reducer, false);
3  const open = () => dispatch({ type: 'OPEN' });
4  const close = () => dispatch({ type: 'CLOSE' });
5  return { isOpen, open, close };
6}

La valeur par défaut = {} permet d'appeler useAirlock() sans arguments. Le consommateur ajoute maintenant sa propre règle : tant que le vaisseau n'est pas amarré, le sas ne s'ouvrira pas :

1function DockingBay({ docked }) {
2  const airlock = useAirlock({
3    reducer: (isOpen, action) => {
4      if (action.type === 'OPEN' && !docked) {
5        return isOpen; // dans le vide, le sas reste fermé
6      }
7      return airlockReducer(isOpen, action);
8    },
9  });
10
11  return (
12    <button onClick={airlock.isOpen ? airlock.close : airlock.open}>
13      {airlock.isOpen ? 'Fermer le sas' : 'Ouvrir le sas'}
14    </button>
15  );
16}

Le consommateur n'intercepte qu'une seule action et confie le reste à l'airlockReducer par défaut, donc useAirlock n'a pas changé du tout. Le reducer doit rester une fonction pure : pas de window.confirm, pas de requêtes vers une API, pas de modification d'objets, car en développement, sous <StrictMode>, React l'appelle deux fois.

Quel pattern choisir ?

Chacune de ces techniques répond à un besoin différent du consommateur :

SituationApproche
Même structure, contenu différent de ses partiesSlot Pattern
Éléments HTML ou composants de routeur différentsComposant polymorphe (as)
Apparence différente des éléments d'une listeInjection de composant (renderItem)
Attributs et handlers tout prêts à étaler sur un élémentProps collection, ou mieux un prop getter
Règles de changement d'état différentesState Reducer

Niveaux de contrôle

Les mêmes techniques peuvent se classer selon la quantité de contrôle que tu cèdes au consommateur :

  1. Props : le moins d'IoC, le composant reçoit des données et décide seul comment les afficher.
  2. Slots et children : le consommateur décide du contenu de certains emplacements.
  3. Render props et component injection : le consommateur décide de tout le rendu d'un élément.
  4. State reducer : le plus d'IoC, le consommateur modifie même la logique d'état.

Plus on monte dans cette liste, plus le composant est flexible, mais aussi difficile à utiliser et à documenter. C'est comme sur la passerelle : trop peu de commandes empêchent le pilote de réagir à une surprise, et trop de commandes le ralentissent à chaque manœuvre. Mon conseil : commence par le niveau le plus bas qui résout le problème, et ne monte plus haut que lorsque le consommateur a vraiment besoin de plus. Dans la prochaine leçon, tu céderas le contrôle encore plus loin : l'équipe activera et désactivera des fonctionnalités entières avec des drapeaux, sans déployer de nouveau code.

Retiens : un composant à contrôle inversé, c'est une prise sur le vaisseau qui fournit le connecteur, et c'est le consommateur qui décide de ce qu'il y branche.

Code de cette leçon : App.jsx
1import React, { useState, useReducer } from 'react';
2
3// === SLOT PATTERN (slots nommés) : SpaceCard ===
4function SpaceCard({ header, footer, actions, children }) {
5  return (
6    <div className="space-card">
7      {header && <div className="card-header">{header}</div>}
8      <div className="card-body">{children}</div>
9      {actions && <div className="card-actions">{actions}</div>}
10      {footer && <div className="card-footer">{footer}</div>}
11    </div>
12  );
13}
14
15// === COMPOSANT POLYMORPHE : SpaceButton avec la prop "as" ===
16function SpaceButton({ as: Component = 'button', variant = 'primary', children, ...props }) {
17  const cls = `space-btn btn-${variant}`;
18  return (
19    <Component className={cls} {...props}>
20      {children}
21    </Component>
22  );
23}
24
25// === INJECTION DE COMPOSANT : MissionList avec renderItem et emptyState ===
26function MissionList({ missions, renderItem, emptyState }) {
27  if (missions.length === 0) {
28    return emptyState || <p className="empty">Aucune mission</p>;
29  }
30  return (
31    <ul className="mission-list">
32      {missions.map((mission, index) => (
33        <li key={mission.id}>{renderItem(mission, index)}</li>
34      ))}
35    </ul>
36  );
37}
38
39// La carte de mission remplit les slots de SpaceCard
40function MissionCard({ mission, onCancel }) {
41  const [showStatus, setShowStatus] = useState(false);
42  return (
43    <SpaceCard
44      header={<h4>{mission.name}</h4>}
45      actions={
46        <>
47          <SpaceButton aria-expanded={showStatus} onClick={() => setShowStatus((s) => !s)}>Détails</SpaceButton>
48          <SpaceButton variant="danger" onClick={onCancel}>Annuler</SpaceButton>
49        </>
50      }
51      footer={<span className="meta">Priorité : {mission.priority}/10</span>}
52    >
53      <p>Distance : {mission.distance} al</p>
54      {showStatus && <p>Statut : {STATUS_LABELS[mission.status]}</p>}
55    </SpaceCard>
56  );
57}
58
59// === PROPS COLLECTION ET PROP GETTER : useToggle ===
60function useToggle(initialState = false) {
61  const [isOpen, setIsOpen] = useState(initialState);
62  const toggle = () => setIsOpen((prev) => !prev);
63
64  // props collection : un objet tout prêt
65  const toggleProps = { onClick: toggle, 'aria-expanded': isOpen };
66
67  // prop getter : fusionne les props du consommateur avec les nôtres
68  const getToggleProps = ({ onClick, ...props } = {}) => ({
69    ...props,
70    'aria-expanded': isOpen,
71    onClick: (event) => {
72      onClick?.(event);
73      toggle();
74    },
75  });
76
77  return { isOpen, toggleProps, getToggleProps };
78}
79
80// === STATE REDUCER : un sas avec des règles par défaut ===
81function airlockReducer(isOpen, action) {
82  switch (action.type) {
83    case 'OPEN': return true;
84    case 'CLOSE': return false;
85    default: return isOpen;
86  }
87}
88
89function useAirlock({ reducer = airlockReducer } = {}) {
90  const [isOpen, dispatch] = useReducer(reducer, false);
91  const open = () => dispatch({ type: 'OPEN' });
92  const close = () => dispatch({ type: 'CLOSE' });
93  return { isOpen, open, close };
94}
95
96// === LIBELLÉS ET DONNÉES ===
97// Les statuts restent en anglais dans les données (ils forment les classes CSS), l’écran affiche des libellés français
98const STATUS_LABELS = { active: 'En cours', completed: 'Terminée' };
99
100const INITIAL_MISSIONS = [
101  { id: 1, name: 'Expédition vers Alpha du Centaure', distance: '4,37', status: 'active', priority: 9 },
102  { id: 2, name: 'Patrouille près de Sirius', distance: '8,6', status: 'completed', priority: 5 },
103  { id: 3, name: 'Étude du système de Tau Ceti', distance: '11,9', status: 'active', priority: 8 },
104];
105
106// === COMPOSANT PRINCIPAL ===
107export default function App() {
108  const [updatedAt, setUpdatedAt] = useState('à l’instant');
109  const [lastTag, setLastTag] = useState(null);
110  const [view, setView] = useState('compact');
111  const [missions, setMissions] = useState(INITIAL_MISSIONS);
112  const [notes, setNotes] = useState(0);
113  const [docked, setDocked] = useState(false);
114  const report = useToggle();
115  const airlock = useAirlock({
116    // règle du consommateur : sans amarrage, l’action OPEN ne change rien
117    reducer: (isOpen, action) => {
118      if (action.type === 'OPEN' && !docked) return isOpen;
119      return airlockReducer(isOpen, action);
120    },
121  });
122
123  const showTag = (e) => {
124    e.preventDefault();
125    setLastTag(e.currentTarget.tagName.toLowerCase());
126  };
127  const addNote = () => setNotes((n) => n + 1);
128  const cancelMission = (id) => setMissions((prev) => prev.filter((m) => m.id !== id));
129
130  return (
131    <div className="app">
132      <h1>Démo : Inversion of Control</h1>
133      <p className="subtitle">Slots, prop as, renderItem, prop getter et state reducer</p>
134
135      <section className="section">
136        <h3>Slots (SpaceCard)</h3>
137        <SpaceCard
138          header={<h4>État du vaisseau</h4>}
139          actions={<SpaceButton onClick={() => setUpdatedAt(new Date().toLocaleTimeString('fr-FR'))}>Actualiser</SpaceButton>}
140          footer={<span className="meta">Dernière mise à jour : {updatedAt}</span>}
141        >
142          <p>Carburant : 87 % | Vitesse : 14 200 km/s</p>
143        </SpaceCard>
144        <SpaceCard header={<h4>Archives</h4>}>
145          <p>Sans les slots actions et footer, la carte ne dessine pas de barres vides.</p>
146        </SpaceCard>
147      </section>
148
149      <section className="section">
150        <h3>Composant polymorphe (prop as)</h3>
151        <div className="btn-row">
152          <SpaceButton onClick={showTag}>Bouton</SpaceButton>
153          <SpaceButton as="a" href="#rapports" onClick={showTag}>Lien</SpaceButton>
154          <SpaceButton as="a" href="#archives" variant="danger" onClick={showTag}>Lien (danger)</SpaceButton>
155        </div>
156        <p className="meta">Élément cliqué : {lastTag ? '<' + lastTag + '>' : 'aucun pour l’instant'}</p>
157      </section>
158
159      <section className="section">
160        <h3>Injection de composant (renderItem)</h3>
161        <p className="hint">En vue détaillée, Annuler retire une mission. Retire-les toutes pour voir le slot emptyState.</p>
162        <div className="toggle-row">
163          <button className={'chip ' + (view === 'compact' ? 'active' : '')} aria-pressed={view === 'compact'} onClick={() => setView('compact')}>Compact</button>
164          <button className={'chip ' + (view === 'detailed' ? 'active' : '')} aria-pressed={view === 'detailed'} onClick={() => setView('detailed')}>Détaillé</button>
165        </div>
166        <MissionList
167          missions={missions}
168          renderItem={view === 'compact'
169            ? (mission, index) => (
170                <div className="compact-item">
171                  <span>{index + 1}. {mission.name}</span>
172                  <span className={'badge badge-' + mission.status}>{STATUS_LABELS[mission.status]}</span>
173                </div>
174              )
175            : (mission) => <MissionCard mission={mission} onCancel={() => cancelMission(mission.id)} />
176          }
177          emptyState={
178            <div className="empty">
179              <p>La flotte attend les ordres.</p>
180              <SpaceButton onClick={() => setMissions(INITIAL_MISSIONS)}>Restaurer les missions</SpaceButton>
181            </div>
182          }
183        />
184      </section>
185
186      <section className="section">
187        <h3>Props collection ou prop getter (useToggle)</h3>
188        <p className="hint">Les deux boutons reçoivent un onClick du consommateur qui ajoute une note. Avec la collection, il remplace le onClick du hook, donc le rapport ne bascule pas. Le getter appelle les deux.</p>
189        <div className="btn-row">
190          <button className="chip" {...report.toggleProps} onClick={addNote}>Collection + ton onClick</button>
191          <button className="chip" {...report.getToggleProps({ onClick: addNote })}>Getter + ton onClick</button>
192        </div>
193        <p className="meta">Notes du consommateur : {notes} | Rapport : {report.isOpen ? 'ouvert' : 'fermé'}</p>
194        {report.isOpen && <p className="report">Rapport de mission : tous les systèmes fonctionnent.</p>}
195      </section>
196
197      <section className="section">
198        <h3>State Reducer (useAirlock)</h3>
199        <label className="check">
200          <input type="checkbox" checked={docked} onChange={(e) => setDocked(e.target.checked)} />
201          Vaisseau amarré
202        </label>
203        <div className="btn-row">
204          <SpaceButton onClick={airlock.open}>Ouvrir le sas</SpaceButton>
205          <SpaceButton variant="danger" onClick={airlock.close}>Fermer le sas</SpaceButton>
206        </div>
207        <p className="meta">Sas : {airlock.isOpen ? 'ouvert' : 'fermé'}{docked ? '' : ' (sans amarrage, la règle du consommateur ignore OPEN)'}</p>
208      </section>
209    </div>
210  );
211}

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Que signifie l'Inversion of Control (IoC) dans le contexte des composants React ?

  2. 2. Qu'est-ce que la prop « as » permet dans les composants polymorphes, ex. <SpaceButton as="a" href="/link"> ?

Tâches pratiques dans le jeu

  • Éditeur de code

    Termine trois composants qui donnent le contrôle au consommateur. SpaceCard (Slot Pattern) : ___BLANK1___ et ___BLANK2___ sont les conditions qui font apparaître <header> et <footer> seulement quand le consommateur a passé la prop header ou footer (comme actions fonctionne déjà). SpaceButton (polymorphe) : ___BLANK3___ est le nom sous lequel on renomme la prop as pour l'utiliser en JSX comme élément, et ___BLANK4___ transmet à l'élément les autres props (onClick, href, to). MissionList (Component Injection) : ___BLANK5___ est l'élément affiché à la place d'une liste vide (s'il n'est pas fourni, le message par défaut reste), et ___BLANK6___ appelle la fonction renderItem avec la mission et son index.

  • Tri vertical

    Range les niveaux d'Inversion of Control en React, du MOINS au PLUS de contrôle cédé :

  • Tri horizontal

    Range la syntaxe pour définir un composant polymorphe avec la prop « as » :

Articles utiles