Accueil / Articles

Résoudre le surcharge de propriétés dans React grâce à la composition et aux slots

Découvrez pourquoi les props de React à forte charge en configuration engendrent une dette de maintenance, et comment l’inversion du contrôle, la composition et les slots permettent de créer des composants véritablement réutilisables.

Éditeur : REACTAPP.TOP11 min de lecture2538 mots

Sources: Your React Components Aren’t Reusable. They’re Just Complicated

Un aperçu de la manière dont les propriétés déterminées par la configuration accumulent silencieusement des dettes de maintenance, et de la façon dont l’inversion du contrôle associée à la composition permet d’obtenir des composants UI véritablement modulaires.

Imaginez une demande de pull request d’un collègue présentant ce qu’il qualifie fièrement de "Tableau de données universel."

L’objectif est de répondre à tous les besoins liés à l’affichage des tableaux dans l’ensemble du code en une seule fois.

Celui-ci intègre trente-quatre propriétés distinctes :

<UniversalDataTable
  data={users}
  columns={columns}
  enablePagination={true}
  paginationType="cursor"
  enableSorting={true}
  showFilterRow={true}
  hasExportToCsvButton={true}
  exportButtonText="Download CSV"
  headerBackground="light-gray"
  denseRows={false}
  enableRowSelection={true}
  selectionMode="multiple"
  renderCustomRowActions={(row) => <DropdownMenu row={row} />}
  onRowClick={(row) => navigate(`/users/${row.id}`)}
/>

En arrière-plan se trouvent environ 1 200 lignes de logique.

Pour afficher une liste d’utilisateurs, il fonctionne très bien.

Quelques semaines plus tard, le service marketing demande un tableau de comparaison des prix à mettre en ligne sur le site public.

Les exigences : trois colonnes, des icônes de coche à côté de chaque ligne de fonctionnalité, ainsi qu’un encadrement souligné « Populaire » autour de la colonne centrale.

Quelqu’un cherche à utiliser <UniversalDataTable />, en pensant qu’il fonctionnera simplement.

C’est alors que les choses se gâtent.

Il n’existe aucun moyen de personnaliser les bordures des cellules individuelles.

L’alignement des en-têtes est fixé par défaut dans un layout flex spécifique.

Désactiver le bouton d’exportation en CSV signifie devoir passer hasExportToCsvButton={false} ainsi que quelques autres paramètres de remplacement pour obtenir un résultat propre.

Ainsi, l’équipe ajoute quatre autres propriétés :

isPricingTable
popularColumnIndex
customHeaderRenderer
disableGlobalStyles

Le fichier passe à 1 450 lignes.

À ce stade, il n’est plus du tout réutilisable.

Ce qui a commencé comme une abstraction s’est transformé en un enchevêtrement de cas particuliers prétendant être un seul composant.

1. Le piège des propriétés de configuration

Lorsqu’une équipe souhaite que un composant soit « réutilisable », la solution instinctive est généralement une configuration via des propriétés.

Chaque nouveau cas d’usage déclenche l’ajout d’un autre indicateur, d’une énumération de chaînes, d’une fonction de rappel ou d’une surcharge de style à l’interface des props.

Un exemple typique :

interface ButtonProps {
  variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
  size?: 'sm' | 'md' | 'lg';
  isLoading?: boolean;
  hasLeftIcon?: boolean;
  leftIcon?: React.ReactNode;
  hasRightIcon?: boolean;
  rightIcon?: React.ReactNode;
  isFullWidth?: boolean;
  tooltipText?: string;
  tooltipPosition?: 'top' | 'bottom';
  rounded?: boolean;
}

En apparence, cela semble adaptable.

Le problème est que cette flexibilité basée sur la configuration entraîne des coûts qui ne sont pas immédiatement visibles.

1. Explosion combinatoire

Déjà, dix seuls props booléens génèrent en théorie 1 024 combinaisons possibles.

Il n’est pas nécessaire de les tester toutes pour comprendre à quel point la situation peut rapidement échapper au contrôle.

Certaines combinaisons sont contradictoires entre elles.

D’autres resteront simplement inutilisées à jamais.

D’autres encore afficheront des résultats incohérents en fonction de la largeur de la fenêtre d’affichage, du contenu environnant ou du niveau de nidification du composant.

Au fil du temps, on se retrouve avec une pile de cas limites plutôt qu’une API cohérente et prévisible.

2. Exploration des props

Imaginons qu’une configuration de tooltip doive atteindre un conteneur imbriqué situé à plusieurs niveaux profonds dans l’arbre des composants.

Chaque composant intermédiaire doit alors transmettre une propriété dont il n’a pas réellement besoin.

La configuration est propagée à travers les différents niveaux uniquement parce que la structure l’exige, et non parce qu’elle a un sens à cet endroit.

C’est le mécanisme à l’origine du phénomène dit « prop drilling ».

3. Hypothèses rigides de mise en page

C’est là que les composants gérés par des configurations échouent généralement définitivement.

Supposons qu’un nouveau design exigee qu’une icône soit placée entre deux lignes de texte de bouton.

Ajouter quelque chose comme :

iconPosition="between"

pourrait temporairement résoudre le problème.

Mais dès que le design change à nouveau, que se passe-t-il ?

Bientôt, vous devrez gérer toute une série de variantes :

iconPosition="left"
iconPosition="right"
iconPosition="top"
iconPosition="between"
iconPosition="absolute"

La liste continue de s’agrandir parce que le JSX interne du composant dicte encore entièrement son layout.

Le véritable problème n’est pas le nombre de props existants.

C’est plutôt que le composant assume trop de responsabilités concernant des éléments hors de son champ d’action.

2. Inversion du contrôle : un meilleur modèle mental

Une véritable réutilisabilité ne s’obtient pas en essayant de prédire à l’avance chaque exigence future.

Elle provient de l’application de la Inversion du contrôle (IoC).

Le concept est simple :

Au lieu que le composant dicte tout ce qui est affiché à l’intérieur de lui, laissez celui qui utilise le composant contrôler les parties qui doivent être modifiées.

C’est précisément pour cette raison que la composition est un pattern si puissant dans React.

Au lieu de créer un bouton doté d’une prise en charge intégrée des aides contextuelles, des icônes, des curseurs de chargement, des badges et de toutes les permutations de mise en page, concevez plutôt un bouton dont la fonction principale est simplement d’être un bouton.

Par exemple :

export function Button({
  children,
  variant = 'primary',
  className = '',
  ...props
}: ButtonProps) {
  return (
    <button
      className={`btn btn-${variant} ${className}`}
      {...props}
    >
      {children}
    </button>
  );
}

Le contrôle du contenu revient désormais à l’utilisateur :

<Tooltip content="Save your profile">
  <Button>
    <SaveIcon />
    <span>Save Changes</span>
  </Button>
</Tooltip>

Remarquez ce qui n’est plus nécessaire.

Le bouton ne sait rien du fait qu’il existe des aides contextuelles.

Il n’y a pas de propriété hasLeftIcon.

Il n’y a pas de propriété leftIcon.

Il n’y a pas de propriété iconSpacing.

Il n’y a pas de propriété hasSpinner.

Besoin d’un curseur de chargement ? Placez-en simplement un directement à l’intérieur du bouton.

<Button>
  <Spinner />
  Saving...
</Button>

Le composant devient plus petit tout en gagnant en flexibilité.

Cet équilibre est la clé de cette approche.

Un composant réutilisable n’a pas besoin de gérer en interne chaque scénario possible. Il a besoin d’une frontière bien définie sur laquelle les autres composants peuvent s’assembler.

3. Les slots facilitent l’extension des composants complexes

L’utilisation de children fonctionne bien pour des éléments simples. Mais que se passe-t-il avec des composants plus complexes tels que des cartes, des dialogues, des tableaux de bord ou des panneaux de profil ?

C’est là que les slots et les composants composés deviennent utiles.

Imaginez une carte de profil conventionnelle construite autour d’un ensemble de propriétés :

<UserProfileCard
  avatarUrl="/avatar.jpg"
  name="Sarah Connor"
  role="Tech Lead"
  status="Active"
  bio="Building reliable frontend systems."
  primaryActionLabel="Send Message"
  onPrimaryAction={sendMessage}
  secondaryActionLabel="View Profile"
  onSecondaryAction={viewProfile}
  badgeColor="green"
/>

À première vue, cela semble pratique.

Puis les exigences commencent à évoluer.

L’équipe marketing souhaite que l’identifiant social de la personne soit affiché juste à côté de son nom. L’équipe d’administration des comptes entreprise a besoin d’un bandeau « Suspendu » qui s’étende sur toute la carte. Une autre équipe de produits veut trois boutons d’action au lieu de deux. Un designer décide que l’avatara doit être placé du côté droit plutôt que du gauche.

Chacune de ces demandes se transforme en un élément supplémentaire à gérer.

Une approche plus efficace consiste à présenter le layout sous forme de pièces composables :

<Card>
  <Card.Header>
    <Avatar
      src={user.avatarUrl}
      alt={user.name}
    />
    <div>
      <Card.Title>{user.name}</Card.Title>
      <Card.Subtitle>{user.role}</Card.Subtitle>
    </div>    <Badge variant={user.statusColor}>
      {user.status}
    </Badge>
  </Card.Header>  <Card.Body>
    <p>{user.bio}</p>
  </Card.Body>  <Card.Actions>
    <Button
      variant="secondary"
      onClick={viewProfile}
    >
      View Profile
    </Button>    <Button
      variant="primary"
      onClick={sendMessage}
    >
      Send Message
    </Button>
  </Card.Actions>
</Card>

Désormais, vous pouvez vraiment voir la structure de la carte dans le JSX. Et puisque vous la voyez, vous pouvez la modifier.

Vous voulez un bouton d’action de plus ? Ajoutez-le simplement. Vous avez besoin d’un autre insigne ? Remplacez-le. Il faut ajouter un autre bandeau ? Insérez-le à l’endroit approprié. Vous souhaitez une disposition complètement différente pour l’en-tête ? Écrivez simplement un nouvel en-tête.

Rien de tout cela ne nécessite de modifier un composant partagé surchargé pour prendre en charge une seule nouvelle variante.

4. La composition l’emporte sur la configuration

Ces deux approches posent des questions fondamentalement différentes.

La configuration demande :

"Quel ensemble d’options ce composant doit-il exposer ?"

La composition demande :

"Quels petits éléments de base les utilisateurs peuvent-ils assembler pour obtenir ce dont ils ont réellement besoin ?"

La configuration continue d’imposer davantage de responsabilités décisionnelles au composant lui-même. La composition restitue ces décisions à ceux qui utilisent le composant. C’est généralement là que cette responsabilité doit résider.

Prenons cet exemple pour illustrer :

<Button
  hasLeftIcon
  leftIcon={<SaveIcon />}
  hasTooltip
  tooltipText="Save your profile"
  showSpinner={isSaving}
  spinnerPosition="left"
/>

contre :

<Tooltip content="Save your profile">
  <Button>
    {isSaving && <Spinner />}
    <SaveIcon />
    Save Profile
  </Button>
</Tooltip>

Le deuxième extrait contient effectivement plus de JSX. Mais il présente également beaucoup moins de couplage entre les éléments qui n’ont pas besoin de se connaître. Ce compromis en vaut généralement la peine : un peu plus de balises supplémentaires au point d’appel s’avère souvent moins coûteux à long terme que d’ajouter une fonctionnalité permanente de plus à un composant partagé.

5. Apprenez des bibliothèques headless

Plusieurs bibliothèques front-end modernes illustrent exceptionnellement bien cette philosophie.

Des projets tels que Radix UI, TanStack Table et shadcn/ui ont joué un rôle majeur dans la popularisation de cette approche axée sur la composition.

TanStack Table en est l’un des exemples les plus clairs. Il ne vous force pas à adopter un design de tableau fixe. Au contraire, il fournit la logique et la gestion d’état pour des fonctionnalités telles que :

  • Le tri
  • Le filtrage
  • La pagination
  • La sélection de lignes
  • Gestion des colonnes
  • État de la table
  • C’est entièrement à vous de transformer cet état en balises réelles. Par exemple :

    <table>
      <thead>
        {table.getHeaderGroups().map((headerGroup) => (
          <tr key={headerGroup.id}>
            {headerGroup.headers.map((header) => (
              <th key={header.id}>
                {flexRender(
                  header.column.columnDef.header,
                  header.getContext()
                )}
              </th>
            ))}
          </tr>
        ))}
      </thead>
    
      <tbody>
        {/* Your own rendering logic */}
      </tbody>
    </table>
    

    La forme exacte de l’API n’est pas vraiment le point essentiel ici. Ce qui compte, c’est l’architecture sous-jacente : le moteur de table gère le comportement, tandis que vous gérez la présentation. Comme ces deux aspects sont séparés, vous pouvez redessiner l’aspect visuel sans que la logique de base de la table ne devienne un composant visuel géant.

    6. Séparer le comportement de l’apparence

    Cela mène naturellement à une autre directive à suivre :

    Séparez ce qu’un composant fait de la manière dont il est affiché.

    Un seul composant peut légitimement devoir gérer une certaine complexité interne, couvrant des éléments tels que :

    • Navigation par clavier
    • Gestion du focus
    • Sélection
    • Tri
    • Filtrage
    • État
    • Accessibilité
    • Positionnement

    Gérer tout cela correctement n’est rarement une tâche simple. Pourtant, rien dans ce cadre ne force un même composant à déterminer précisément l’aspect du résultat final.

    C’est l’idée fondamentale derrière la plupart des systèmes UI headless : le comportement est réutilisé dans de nombreuses implémentations visuelles, tandis que la couche de présentation reste entièrement ouverte. Cette séparation permet généralement une réutilisabilité bien plus forte que de tenter de créer un seul composant énorme rempli de dizaines de paramètres de configuration visuelle.

    7. Lorsque les props ont réellement du sens

    Tout cela ne signifie pas pour autant que les props constituent un mauvais pattern.

    Ce sont l’un des outils les plus fondamentaux que React met à votre disposition.

    Le problème ne réside pas dans l’existence même des props, mais dans leur utilisation pour encoder chaque variation structurelle imaginable dont un composant pourrait avoir besoin.

    Des exemples comme ceux-ci sont tout à fait acceptables :

    <Button variant="primary" />
    <Button size="sm" />
    <Input disabled />
    <Card className="featured" />
    

    Le drapeau rouge apparaît dès qu’un composant commence à accumuler une liste de ce type :

    <Component
      showHeader
      showFooter
      showBadge
      showIcon
      showActions
      iconPosition="left"
      badgePosition="right"
      footerAlignment="center"
      actionLayout="horizontal"
      compact
      dense
      bordered
      rounded
      disableAnimation
      customHeaderRenderer={...}
      customFooterRenderer={...}
    />
    

    Lorsque vous repérez ce schéma, il vaut la peine de faire une pause pour se demander :

    « Devraient-ils vraiment rester des props, ou certains d’entre eux fonctionneraient-ils mieux en tant qu’enfants composés ? »

    Se poser cette question permet généralement d’éviter de nombreuses complexités inutiles par la suite.

    8. Ce que coûte réellement l’over-abstraction

    Les vrais dommages causés par des composants sur-configurés ne sont pas esthétiques.

    C’est plutôt le fait qu’ils deviennent coûteux à modifier.

    Un composant largement utilisé a un impact important. Si vingt endroits dans votre codebase en dépendent, alors chaque nouveau prop que vous y ajoutez peut avoir des répercussions sur ces vingt utilisateurs.

    Les ingénieurs hésitent alors à le modifier.

    Rédiger de la documentation pour ce composant devient plus difficile.

    Le tester devient plus difficile.

    Détecter les bugs qui y existent devient plus difficile.

    Finalement, le composant devient si complètement « réutilisable » que personne ne souhaite vraiment l’utiliser à nouveau.

    C’est là l’ironie inhérente à ce schéma.

    L’abstraction excessive peut nuire justement à cette réutilisabilité qu’il était censé offrir.

    Une primitive petite et au champ d’application restreint s’avère souvent plus réutilisable dans la pratique qu’un composant volumineux conçu pour anticiper toutes les variations possibles.

    Un cadre pratique pour construire des composants

    1. S’agit-il du comportement ou de la présentation ?

    Si c’est lié au comportement, un prop ou un hook personnalisé est probablement l’outil adapté. S’il s’agit purement de la structure visuelle, la composition est généralement plus appropriée.

    2. Ce composant a-t-il vraiment besoin de ces informations ?

    Si l’on attend d’un seul composant qu’il comprenne les tooltips, les hooks d’analyse, les badges, les icônes, les indicateurs de chargement, ainsi que toute disposition de ses enfants, il a presque certainement pris plus de responsabilités qu’il ne le devrait.

    3. Le children pourrait-il gérer cela à la place ?

    Si c’est possible, privilégiez la composition plutôt que d’introduire un autre prop dédié au contenu.

    4. Des emplacements nommés pourraient-ils clarifier l’API ?

    Pour des composants plus complexes, définir des régions explicites comme :

    <Card.Header />
    <Card.Body />
    <Card.Footer />
    

    pourrait rendre la compréhension de leur structure bien plus simple.

    5. La logique peut-elle être séparée du markup ?

    Si c’est le cas, une approche sans interface visuelle ou axée sur la logique mérite d’être envisagée.

    6. Ce composant répond-il à un besoin réel ou imaginaire ?

    Cette question est plus importante qu’il n’y paraît.

    Résistez à l’envie d’ajouter quelque chose comme :

    futureFeature={true}
    

    simplement parce que quelqu’un pourrait éventuellement en avoir besoin à l’avenir.

    Concevez en fonction de besoins pour lesquels vous disposez réellement d’éléments concrets, et non de ceux que vous supposez seulement.

    Conclusions

    L’objectif de créer des composants React réutilisables n’est pas de produire quelque chose qui puisse faire absolument tout.

    C’est plutôt de créer des éléments qui puissent être assemblés de nombreuses manières différentes.

    Cette distinction semble subtile, mais sur le plan architectural, elle change tout.

    Un composant qui dépend de trente props peut sembler flexible en apparence, mais chacun de ces props ajoute une nouvelle responsabilité, un nouvel état à gérer, ainsi qu’une combinaison supplémentaire que quelqu’un devra finir par prendre en charge.

    En revanche, un composant doté d’une API restreinte et de limites de composition claires peut paraître modeste.

    Cependant, en pratique, il tient bien mieux le coup avec le temps.

    Ainsi, la prochaine fois que vous serez tenté d’ajouter un autre prop à un composant partagé, arrêtez-vous un instant et demandez-vous :

    « Est-ce que je rends cela plus réutilisable, ou simplement plus configurable ? »

    Ces deux aspects ne sont pas interchangeables.

    Un composant ne devient pas réutilisable en accumulant trente options.

    Il devient réutilisable en accomplissant bien une seule tâche, en définissant des limites logiques, et en ne gênant pas le reste du système.

    L’architecture de composants solides ne vise pas à prédire chaque besoin futur.

    Elle permet de composer facilement tous les besoins qui pourraient survenir à l’avenir.