Accueil / Articles / Lorsque les composants React réutilisables ont des effets indésirables : l’explosion des props et la solution

Lorsque les composants React réutilisables ont des effets indésirables : l’explosion des props et la solution

Voyez comment une réutilisation prématurée transforme un composant React simple en une charge lourde de props, et comment la duplication, les composants composés ainsi que la règle des trois permettent d’y remédier.

2023 mots

Les composants partagés sont censés gagner du temps, mais de nombreux projets frontend possèdent un composant que tout le monde évite d’éditer : un <Modal /> ou <Card /> doté de dizaines d’attributs, où la correction d’une erreur sur une seule interface perturbe trois autres. Ce résultat n’est que rarement dû à la négligence ; c’est plutôt l’issue inévitable d’un recours prématuré à la réutilisation de l’interface utilisateur. Cet article montre comment un composant devient une source de problèmes, explique pourquoi la duplication de l’interface est souvent le choix le moins coûteux, et présente deux outils pratiques pour l’éviter : les composants composés et la Règle des Trois.

Comment un raccourci inoffensif devient un composant à trente-deux attributs

Le processus commence généralement par une demande raisonnable. Un designer fournit une page de paiement avec un dialogue presque identique à celui de la page des paramètres. Les différences sont mineures : les actions se trouvent sur la gauche, le titre est accompagné d’une étiquette orange, et une fine ligne de séparation distingue les boutons du contenu.

Lors de l’examen, quelqu’un souligne qu’un <Modal /> existe déjà et propose de l’utiliser à nouveau. Quelques heures plus tard, une demande de fusion arrive qui ajoute quatre propriétés : hasOrangeBadge, alignActionsLeft, showDividerLine et badgeText.

Répétez cela une douzaine de fois par an. Le même modal nécessite désormais trente-deux propriétés, contient quinze ternaires imbriqués et douze indicateurs booléens, et dépend de trois hooks useEffect pour synchroniser les animations internes avec différentes combinaisons de propriétés. Puis quelqu’un ajuste une ligne relative au padding pour corriger un bug de facturation et brise accidentellement le modal sur quatre écrans sans rapport. Les efforts visant à maintenir un code propre et réutilisable ont abouti à un composant que personne ne veut gérer.

Pourquoi le principe DRY s’applique différemment à l’UI

« Ne vous répétez pas » est un conseil judicieux pour une raison précise. Lorsque la logique métier, telle que le calcul d’un paiement ou une vérification de permissions, est dupliquée, une correction appliquée à une copie laisse les autres endommagées, et ces copies s’éloignent progressivement l’une de l’autre.

Les composants UI évoluent pour différentes raisons. Les règles métier changent lorsque le domaine évolue. Les interfaces se modifient en fonction des parcours utilisateurs, des points de rupture responsive, des exigences d’accessibilité, des décisions relatives au produit et des expérimentations de conception, et ces facteurs agissent indépendamment sur chaque écran.

Deux éléments qui se ressemblent ne représentent pas nécessairement le même concept. Fusionner deux modèles UI en un seul composant partagé avant que leurs designs ne soient finalisés crée un couplage entre des fonctionnalités qui n’existaient pas auparavant. Dès lors, une petite modification de conception pour l’onboarding peut nécessiter un rétest des paramètres, du système de facturation et des outils d’analyse, simplement parce qu’ils affichent par hasard le même composant. À ce stade, la réutilisation agit contre vous.

Anatomie de l’explosion des props

Il est utile d’observer l’évolution se produire sprint par sprint. Le point de départ est une carte avec un contrat clair et minimal : un titre, une description et un gestionnaire de clic optionnel.

interface CardProps {
  title: string;
  description: string;
  onClick?: () => void;
}

L’implémentation est tout aussi concise et facile à lire :

export function Card({ title, description, onClick }: CardProps) {
  return (
    <div className="card" onClick={onClick}>
      <h3>{title}</h3>
      <p>{description}</p>
    </div>
  );
}

Il n’y a rien de mal avec ce composant. Puis, au sprint 4, le service marketing souhaite des cartes de blog avec une image en haut, ce qui ajoute deux propriétés d’image optionnelles :

interface CardProps {
  // ...
  imageUrl?: string;
  imageAlt?: string;
}

Au sprint 7, l’équipe du tableau de bord demande un bouton d’action dans un coin, visible uniquement sur les éléments actifs. Cela nécessite un indicateur, une icône et un gestionnaire :

interface CardProps {
  // ...
  hasTopRightAction?: boolean;
  topRightActionIcon?: React.ReactNode;
  onTopRightAction?: () => void;
}

Au sprint 12, l’équipe d’analyse souhaite une deuxième ligne sous le titre, un badge de statut en trois couleurs et un pied de page pouvant s’élargir, ce qui ajoute six nouvelles propriétés :

interface CardProps {
  // ...
  subtitle?: string;
  badgeText?: string;
  badgeVariant?: "success" | "warning" | "danger";
  isExpandable?: boolean;
  expandedContent?: React.ReactNode;
  defaultExpanded?: boolean;
}

Dans le sprint 20, la fonction de rendu est devenue une série de conditions. L’élément racine calcule sa classe à partir d’un indicateur, et l’image n’est affichée que lorsqu’une URL est présente :

export function Card(props: CardProps) {
  return (
    <div
      className={`card ${
        props.isExpandable ? "card-expandable" : ""
      }`}
    >
      {props.imageUrl && (
        <img src={props.imageUrl} alt={props.imageAlt} />
      )}

L’encadrement du titre s’ouvre avec le titre :

      <div className="card-header">
        <div>
          <h3>{props.title}</h3>

suivi d’un sous-titre qui n’apparaît que s’il est fourni :

          {props.subtitle && <h4>{props.subtitle}</h4>}
        </div>

L’action en haut à droite dépend d’un indicateur booléen plutôt que de la présence d’un gestionnaire ; il est donc possible de définir l’indicateur et d’oublier l’icône, ou de passer une icône et d’oublier l’indicateur :

        {props.hasTopRightAction && (
          <button onClick={props.onTopRightAction}>
            {props.topRightActionIcon}
          </button>
        )}
      </div>

Le badge construit un nom de classe à partir de sa variante et passe silencieusement à une variante default que le type ne liste même pas :

      {props.badgeText && (
        <span
          className={`badge badge-${
            props.badgeVariant || "default"
          }`}
        >
          {props.badgeText}
        </span>
      )}

La description, unique élément restant du design initial, se trouve au milieu :

      <p>{props.description}</p>

Et le pied de page extensible ferme le composant. Notez que isExpandable contrôle à la fois la classe racine et le pied de page, tandis que defaultExpanded de l’interface n’est utilisé nulle part dans le markup :

      {props.isExpandable && (
        <div className="card-footer">
          {props.expandedContent}
        </div>
      )}
    </div>
  );
}

Ces petites incohérences sont courantes. Lorsqu’un composant compte autant de flags, personne ne peut voir toutes les combinaisons en même temps, ce qui entraîne l’apparition de states invalides ou partiellement implémentés. Chaque utilisateur de <Card /> doit apprendre une liste toujours plus longue d’options et déterminer quelles combinaisons sont prises en charge. L’abstraction devient alors plus difficile à comprendre que le markup simple qu’elle était censée remplacer.

La duplication est souvent moins coûteuse qu’une mauvaise abstraction

Une citation célèbre en matière de conception logicielle, popularisée par Sandi Metz, le dit sans détour : « La duplication est bien moins coûteuse que l’abstraction erronée. » Cet avis est particulièrement utile dans le développement frontend.

Lorsque deux composants se ressemblent simplement, il est souvent plus sûr de les conserver séparés. Supposons que BillingModal et OnboardingModal soient des composants indépendants :

  • Un changement dans BillingModal ne peut pas affecter OnboardingModal.
  • La suppression de la fonctionnalité d’onboarding entraîne également la suppression de son modal ainsi que de toute sa logique spécifique.
  • Chaque modal évolue en fonction de ses propres exigences.
  • Un petit changement visuel ne nécessite pas de comprendre des dizaines d’attributs appartenant à d’autres fonctionnalités.

La duplication de JSX coûte quelques lignes supplémentaires. Une abstraction erronée coûte bien plus, en termes de temps de débogage, de tests de régression et de maintenance continue. Tout fragment de markup répété ne mérite pas nécessairement un composant partagé.

Composants composés : la composition plutôt que la configuration

La réutilisation véritable a encore sa place, surtout dans un système de conception. L’essentiel réside dans la manière dont le composant partagé offre de la flexibilité. Au lieu d’un seul composant contrôlé par une liste croissante de booléens, un composant composé propose un ensemble de petites parties liées que les utilisateurs assemblent eux-mêmes.

Voici un dialogue de facturation construit de cette manière. Le composant propriétaire conserve l’état ouvert localement :

export function BillingSettings() {
  const [isOpen, setIsOpen] = useState(false);

Le <Modal> racine ne reçoit que ce qu’il possède réellement, l’état ouvert et une fonction de rappel en cas de modification, tandis que la superposition constitue sa propre entité :

  return (
    <Modal open={isOpen} onOpenChange={setIsOpen}>
      <Modal.Overlay />

La zone de contenu contient un en-tête, et cet en-tête comprend un titre :

      <Modal.Content>
        <Modal.Header>
          <Modal.Title>Update Billing Plan</Modal.Title>

Un insigne n’est rien d’autre qu’un enfant de l’en-tête, sa variante étant exprimée via une propriété propre à l’insigne :

          <Modal.Badge variant="warning">
            Action Required
          </Modal.Badge>
        </Modal.Header>

Le corps contient du contenu arbitraire, commençant par un message :

        <Modal.Body>
          <p>
            Please update your payment method to avoid account suspension.
          </p>

suivi d’un formulaire spécifique à une fonctionnalité dont le modal ne sait rien :

          <CreditCardForm />
        </Modal.Body>

Le pied de page gère son propre alignement et contient des boutons ordinaires, le premier d’entre eux fermant la boîte de dialogue :

        <Modal.Footer align="right">
          <Button
            variant="ghost"
            onClick={() => setIsOpen(false)}
          >
            Cancel
          </Button>

L’action principale finalise le pied de page ainsi que l’arborescence :

          <Button variant="primary">
            Save Changes
          </Button>
        </Modal.Footer>
      </Modal.Content>
    </Modal>
  );
}

La différence structurelle est importante. Lorsqu’un modal nécessite un insigne, il n’existe pas de propriété showBadge à ajouter ; on affiche simplement <Modal.Badge />. Lorsqu’une page nécessite du contenu personnalisé, on le place à l’endroit approprié au lieu d’inventer une nouvelle flag. Les avantages en découlent directement :

  • Aucune surcharge des propriétés. Un modal sans insigne ou pied de page ne rend pas <Modal.Badge /> ni <Modal.Footer />.
  • Plus de flexibilité. Une icône à côté du titre est placée à l’intérieur de <Modal.Header> ; aucune nouvelle propriété n’est nécessaire.
  • Stylistique isolée. Modifier <Modal.Badge /> n’implique pas de toucher au conteneur principal.
  • Structure lisible. Le JSX affiche directement le layout, plutôt que de forcer les lecteurs à ouvrir une interface TypeScript pour déterminer quelle combinaison de propriétés produit quel layout.
  • Au niveau technique, les composants composés partagent généralement un état tel que open via le contexte React, ce qui permet à <Modal.Footer> ou à un bouton de fermeture d’y accéder sans avoir recours à des appels en cascade de propriétés. La composition transfère le contrôle à l’utilisateur sans transformer le composant en un objet de configuration. Cette approche n’est pas sans inconvénients : le système de conception doit préciser quels éléments peuvent être imbriqués où, et les utilisateurs doivent écrire un peu plus de markup à chaque utilisation. Pour une autre perspective sur ce refactoring, consultez notre guide sur la correction du surcharge de propriétés grâce à la composition et aux slots.

    La règle des trois pour extraire des composants partagés

    Une heuristique simple aide à déterminer quand une abstraction est justifiée : attendre la troisième occurrence réelle.

    Première occurrence : l’écrire directement

    Placer le marquage directement à l’intérieur de la vue qui en a besoin. Résistez à l’envie d’abstraire et conservez les styles et la structure à côté de la fonctionnalité concernée.

    Deuxième occurrence : copier et adapter

    Lorsqu’un autre écran a besoin de quelque chose de similaire, créer un composant global devient très tentant. Au lieu de cela, copiez le marquage et ajustez-le à son nouveau contexte. Vous disposez ainsi de deux exemples concrets, et avec le temps vous pourrez observer où ils divergent réellement plutôt que de spéculer sur des besoins futurs.

    Troisième occurrence : extraire avec des preuves

    Lorsqu’un troisième écran distinct a besoin du même schéma visuel et comportemental, on dispose enfin de suffisamment d’éléments pour identifier ce qui est réellement partagé et ce qui diffère. Attendre si longtemps permet de mettre en évidence les véritables invariants, c’est-à-dire les éléments qui restent identiques à chaque fois, ainsi que les véritables variations qui doivent rester flexibles. Ces dernières constituent de bons candidats pour des emplacements de composition plutôt que pour des propriétés booléennes.

    L’objectif n’est pas d’éviter les composants réutilisables. Il s’agit plutôt d’éviter de construire des abstractions sur des hypothèses.

    Points clés

    • Faites attention à la prolifération des propriétés. Lorsqu’un composant accumule constamment des propriétés de configuration pour des cas d’usage non liés, remettez en question cette abstraction avant d’en ajouter une autre.
    • Préférez la composition à la configuration. Les enfants, les emplacements et les composants composés offrent de la flexibilité sans nécessiter une nouvelle propriété booléenne à chaque modification du design.
  • Acceptez de petites quantités de duplication. Un ensemble de composants petits et distincts qui partagent une partie du markup est souvent plus facile à maintenir qu’un seul composant tentant de couvrir toutes les variantes.
  • Appliquez la règle des trois. Laissez les composants partagés émerger de plusieurs cas d’usage réels plutôt que de prédictions.
  • Une bonne architecture frontend ne se mesure pas au nombre minimal de lignes de code, mais à la capacité du code à changer en toute sécurité sans provoquer d’effets secondaires dans l’application. Parfois, le meilleur composant n’est pas celui qui est réutilisé partout, mais celui qui reste isolé.

    Lectures complémentaires

  • Où se trouve la limite du framework dans un stack UI reutilisable — Découvrez comment les machines à états, les composants Web ainsi que le mise en page et les animations basés sur des attributs permettent au comportement d’une interface utilisateur de survivre à un framework, et quand cette portabilité n’en vaut pas la peine.
  • Duplication versus couplage : décider quand du code partagé doit exister — Apprenez pourquoi fusionner des composants React ou des services NestJS similaires peut coûter plus cher que la duplication, et utilisez trois questions pour déterminer quand une abstraction mérite d’être mise en place.
  • Design par traites pour React : couches, règles d’importation et quand arrêter — Découvrez comment les couches, les règles d’importation unidirectionnelles, les API publiques simplifiées et les importations croisées @x du design par traites permettent de simplifier les bases de code React, ainsi que le moment opportun pour n’en adopter qu’une partie.