Concevoir vos composants React avec des slots multiples

Concevez des composants réutilisables avec de multiples slots pour mutualiser les comportements communs tout en permettant de personnaliser leur rendu et contenu.

  1. Introduction
    1. Factoriser le markup, personnaliser le contenu
    2. Abstraire les comportements
  2. À propos des slots
  3. Les slots dans React
    1. Slot par défaut
    2. Slots multiples via props
    3. Slots multiple via JSX
    4. Bonus: exemple de DropMenu
  4. Conclusion
  5. Aller plus loin & sources

Introduction

En tant que développeur React, vous avez probablement déjà été confrontés à la création de composants génériques. Ces composants sont souvent utilisés pour mutualiser des comportements communs à plusieurs endroits de votre application et nécessitent d'être suffisamment personnalisables pour pouvoir s'adapter à la situation.

Factoriser le markup, personnaliser le contenu

Par exemple, un composant Modal peut être constitué de plusieurs sous-parties dont vous souhaiteriez uniquement personnaliser le contenu :

Sous-parties simplifiées d'un composant modal

Et dont le rendu HTML pourrait être le suivant :

<div class="modal">
    <div class="modal-header">
        <!-- Title -->
        <h2>Objectifs</h2>
    </div>

    <div class="modal-content">
        <!-- Content -->
        <p>Contenu de ma modale</p>
        <a href="">En découvrir plus…</a>
    </div>

    <div class="modal-footer">
        <!-- Footer -->
        <button onClick="">Fermer</button>
    </div>
</div>

Chacune des sous-parties Title, Content et Footer de ce composant possède autour de son contenu un markup HTML qui lui est propre et qu'il convient de ne pas avoir à répéter à chaque utilisation du composant <Modal> :

<Modal>
  {/* Injection d'un simple texte dans le titre de la modale */}
  <Modal.Title>
    Objectifs
  </Modal.Title>

  {/* Contenu JSX */}
  <Modal.Content>
    <p>Contenu de ma modale</p>
    <a href="">En découvrir plus…</a>
  </Modal.Content>

  {/*
    Fonction de rendu utilisée pour le contenu du footer,
    se voyant injecté la fonction de fermeture de la modale
  */}
  <Modal.Footer>{({ close }) =>
    <button onClick={close}>Fermer</button>
  }</Modal.Footer>
</Modal>

De cette façon, il n'y a plus qu'à fournir le contenu dynamique de ces sous-parties, le reste du markup étant abstrait.

Abstraire les comportements

Voici un composant DropMenu :

Représentation d'un composant DropMenu

Dont le code JSX, à l'utilisation serait le suivant :

<DropMenu>
  {/* Bouton déclenchant l'apparition du menu dropdow */}
  <DropMenu.Trigger>
    <button type="button">Bonjour John Doe</button>
  </DropMenu.Trigger>

  {/* Éléments du menu à afficher */}
  <DropMenu.Items>
    <DropMenu.Item>
      <Link to={route(Profile)}>Mon profil</Link>
    </DropMenu.Item>
    <DropMenu.Item>
      <Link to={route(Projects)}>Mes projets</Link>
    </DropMenu.Item>
    <DropMenu.Item>
      <Link to={route(Logout)}>Déconnexion</Link>
    </DropMenu.Item>
  </DropMenu.Items>
</DropMenu>

Ici, l'élément <DropMenu.Trigger> est utilisé pour définir un comportement sur le <button> qu'il encapsule afin de déclencher l'affichage des sous-éléments du <DropMenu.Items>.
Dans son fonctionnement interne, le composant <DropMenu> viendra décorer le composant encapsulé pour lui ajouter automatiquement la propriété onClick avec la logique correspondante.

Les éléments Items & Item s'assurent quant à eux de fournir le bon markup HTML pour la liste des éléments du menu, sans avoir à répéter ce markup à chaque utilisation du composant DropMenu.

Ces éléments constitutifs d'un composant réutilisable, définissant des emplacements spécifiquement identifiables - permettant de décorer, mutualiser et personnaliser leur contenu - sont communément appelés des slots.

À propos des slots

Le terme slot est bien connu des développeurs utilisant Vue.js, lequel formalise ce concept en exposant explicitement un élément <slot> pour définir un emplacement du composant dans lequel le contenu fourni sera injecté.

Diagramme de définition des slots dans Vue.js

Il est aussi possible de nommer ces slots afin de pouvoir en utiliser plusieurs au sein d'un même composant :

Diagramme de définition des slots nommés dans Vue.js

Nous aboutissons alors à un markup similaire à notre exemple de modale ci-dessus :

<Modal>
  <template #title>Objectifs</template>

  <template #content>
    <p>Contenu de ma modale</p>
    <a href="">En découvrir plus…</a>
  </template>

  <template #footer>
    <!-- … -->
  </template>
</Modal>

Le concept est disponible à l'identique dans l'API des Web Components.

Les slots dans React

React n'évoque à proprement parler jamais la notion de slots.
Cependant, il est bel et bien possible de se baser sur cette notion lors de la conception de vos composants. Vous l'avez probablement déjà fait sans le savoir.

Slot par défaut

Le cas le plus simple est celui d'un composant qui ne possède qu'un seul slot, celui par défaut, connu comme la propriété children disponible dans tout composant React :

// App.jsx
<Modal>
  {/* Slot par défaut, disponible en tant que propriété `children` du composant Modal */}
  <p>Contenu de ma modale</p>
  <a href="">En découvrir plus…</a>
</Modal>
// Modal.jsx
function Modal({ children }) {
  return <div className="modal">
    <div className="modal-content">
      {children} {/* <-- Contenu injecté du slot par défaut */}
    </div>
  </div>;
}

Inconvénient : le contenu JSX passé à l'intérieur de <Modal> n'est disponible que sous une seule et même propriété children, limitant virtuellement le nombre de slots disponibles à un seul. Il existe cependant une façon naturelle de contourner ce problème.

Slots multiples via props

Il est possible de définir plusieurs slots en utilisant des props dédiées :

// App.jsx
<Modal
  title="Objectifs"
  content={<>
    <p>Contenu de ma modale</p>
    <a href="">En découvrir plus…</a>
  </>}
  footer={({ close }) => 
    <button onClick={close}>Fermer</button>
  }
/>
// Modal.jsx
function Modal({ content, title, footer }) {
  const close = () => {/* … */};

  return <div className="modal">
    <div className="modal-header">
      <h2>{title}</h2>
    </div>

    <div className="modal-content">
      {content}
    </div>

    <div className="modal-footer">
      {footer({ close })}
    </div>
  </div>;
}

Inconvénient : à mesure que vos composants, le nombre de slots, ou les contenus injectés augmentent, la lisibilité est compromise. Par exemple, avec un composant plus riche :

// App.jsx
<Modal
  title={<span>Un contenu <strong>plus riche</strong></span>}
  icon={<Icon name="info" />}
  closeIcon={<Icon name="close" />}
  content={<>
    <p>Contenu de ma modale</p>

    <ul>
      <li>Un</li>
      <li>Deux</li>
      <li>Trois</li>
    </ul>

    <a href="">En découvrir plus…</a>
  </>}
  footer={({ close }) => <>
    <button onClick={{/* … */}}>Annuler</button>
    <button onClick={close}>Accepter</button>
  </>}
/>

L'intérêt du JSX étant d'exposer une syntaxe proche du HTML, il est moins naturel à la lecture de retrouver ces contenus en tant que propriétés plutôt que nœuds enfants d'un composant.

Il est bien sûr possible de faire un mix des deux approches, en utilisant children pour définir l'un des slots :

// App.jsx
<Modal
  title="Objectifs" 
  footer={({ close }) => 
    <button onClick={close}>Fermer</button>
  }
>
  <p>Contenu de ma modale</p>
  <a href="">En découvrir plus…</a>
</Modal>

bien que ce choix puisse sembler arbitraire et ne résolve pas notre problématique lors de la multiplication des slots acceptant du JSX.

Slots multiple via JSX

Il est possible d'obtenir une structure plus naturelle en utilisant le markup JSX pour définir les slots, des sous-composants React pour chaque et en utilisant les APIs React visant à manipuler la propriété children.

Afin d'aboutir à une telle syntaxe :

// App.jsx
<Modal>
  <Modal.Title>Objectifs</Modal.Title>

  <Modal.Content>
    <p>Contenu de ma modale</p>
    <a href="">En découvrir plus…</a>
  </Modal.Content>

  <Modal.Footer>{({ close }) =>
    <button onClick={close}>Fermer</button>
  }</Modal.Footer>
</Modal>

commençons par créer nos sous-composants dans notre fichier Modal.jsx:

// Chacun de nos slots est un sous-composant React dédié, 
// permettant de l'identifier par son type :

function Title({ children }) {
  return <h2>{children}</h2>;
}

function Content({ children }) {
  return <div className="modal-content">
    {children}
  </div>;
}

function Footer({ children, close }) {
  return <div className="modal-footer">
    {/* 
      Dans le cas où le contenu de <Modal.Footer> est une fonction,
      nous l'invoquons en lui transmettant la fonction de fermeture de la modale
    */}
    {typeof children === 'function' ? children({ close }) : children}
  </div>;
}

Par la suite, nous allons avoir besoin d'une fonction pour identifier chacun de nos sous-composants parmi les enfants de notre <Modal> :

/**
 * Fonction utilitaire permettant de trouver un noeud enfant correspondant à un type donné
 */
function findSlotOfType(children, slotType) {
  return Children.toArray(children).find((child) => child.type === slotType);
}

En la matière, Children.toArray permet d'obtenir un tableau linéarisé du contenu passé à notre <Modal>.
Nous pouvons alors rechercher, isoler et manipuler nos slots parmi ces nœuds en les identifiant par leur type.

Cette implémentation se repose sur la façon de fonctionner de JSX, qui expose une propriété type sur chacun des éléments qu'il crée.

Dès lors, nous pouvons agencer et récupérer le contenu de chacun de nos slots au sein de notre composant Modal :

import React, { Children, cloneElement } from 'react';

function Modal({ children }) {
  const close = () => {/* … */};

  // Récupération de chaque slot : 
  const TitleComponent = findSlotOfType(children, Title);
  const ContentComponent = findSlotOfType(children, Content);
  const FooterComponent = findSlotOfType(children, Footer);

  // Clone de l'élement FooterComponent pour lui passer la fonction close
  const FooterEl = FooterComponent ? cloneElement(FooterComponent, { close }) : undefined;

  return <div className="modal">
    {TitleComponent}
    {ContentComponent}
    {FooterEl}
  </div>;
}

Enfin, nous exportons notre composant modal et chacun de ses sous-composants en tant que propriétés de ce dernier, créant ainsi un espace de nom commun à tous ces éléments (a.k.a namespaced components) :

// https://react-typescript-cheatsheet.netlify.app/docs/advanced/misc_concerns#namespaced-components
export default Object.assign(Modal, { Title, Content, Footer });

Retrouvez le code complet pour cet exemple sur ce gist.

Inconvénient : Il n'est - à priori - pas possible de rendre un slot obligatoire de façon à ce que l'analyse statique remonte une erreur si celui-ci n'est pas fourni, y compris avec TypeScript.

Il est par contre possible de lever une exception à l'exécution si le slot n'est pas trouvé :

function Modal({ children }) {
  // …
  const ContentComponent = findSlotOfType(children, Content);

  if (!ContentComponent) {
    throw new Error('You MUST provide a <Modal.Content> component as a child of <Modal>.');
  }
  // …
}

Bonus: exemple de DropMenu

Vous pouvez consulter sur ce gist l'implémentation d'un composant <DropMenu> utilisant cette approche, TypeScript et MUI Base, relatif à l'exemple de notre introduction.

Conclusion

React ne suggère pas de manière explicite la notion de slots, mais il est possible de s'inspirer de ce concept en exploitant les propriétés de JSX et children afin de simuler l'utilisation de slots multiples avec une syntaxe proche du HTML, semblable à ce qui peut se faire avec d'autres frameworks.

Cette approche n'est cependant peut-être pas à généraliser à tous les composants que vous écrirez ; la plupart des composants pouvant très bien s'accommoder de l'utilisation de simples props pour ce besoin.

Aller plus loin & sources

Vous pouvez poursuivre la réflexion et découvrir d'autres approches au sein de cet excellent article de Sandro Roth : « Building Component Slots in React »

ou consulter ces ressources additionnelles :

Crédits: photo de couverture par Lautaro Andreani