Designer des composants UI réutilisables

Depuis plusieurs années, de nombreux frameworks UI tels que React, Vue ou Angular ont adopté le paradigme du composant. Le développeur va définir un ensemble de composants qu'il va ensuite composer pour construire son interface graphique. Les promesses du composant sont :

  • l'encapsulation - un composant va cacher la complexité nécessaire pour implémenter une fonctionnalité au monde extérieur, avec lequel il communiquera via une interface publique, souvent appelée props ou inputs
  • la réutilisabilité - un composant, s'il est correctement designé, va pouvoir être réutilisé à plusieurs endroits de l'interface pour en accélérer le développement.

C'est sur ce deuxième point que j'aimerais m'attarder aujourd'hui. Dans cet article, nous allons explorer ensemble les façons de designer des composants dont la réutilisabilité sera optimale.

Dessine moi un <Button />

Nous commençons un projet from scratch, quelle chance ! Alors voici notre première user story : nous allons développer le formulaire de login suivant :

Composants Réutilisables - Formulaire

Alors on écrit notre HTML, CSS et JavaScript. Le designer vient nous voir, et nous prévient que certains des éléments de ce formulaire seront réutilisés dans l'ensemble de l'application. C'est d'ailleurs le cas de ce button au background dégradé. Très chic. En bon développeur, et pour éviter à mes collègues d'avoir à recoder le dégradé, ainsi que l'ensemble du style du button, je décide de créer un composant réutilisable <Button /> dans notre nouveau projet. J'extrais donc le code du button dans son propre fichier :

1
2
3
4
5
6
7
8
9
// J'utilise ici styled-components pour définir mes composants, mais 
// le principe reste applicable pour n'importe quelle technologie
export const Button = styled.button`
  padding: 1rem 2rem;
  margin-left: 2rem;
  flex: 1;
  color: #fff;
  background: linear-gradient(to-right, blue, red);
`

Notre interface est maintenant développée et nous disposons en bonus d'un composant <Button /> réutilisable pour la suite du projet, super !

Composants Réutilisables - Button

Mais quelques coquilles se sont cachées dans le refactoring, les avez-vous remarquées ?

Qu'est-ce qu'un composant réutilisable ?

Pour qu'un composant soit réutilisable, il faut réussir à en extraire l'essence pour retirer tout attribut qui est en fait dépendant de son contexte. Le composant doit être agnostique du contexte dans lequel il sera réutilisé, il ne doit pas porter de code qui fasse des suppositions sur son utilisation. Dans l'exemple précédent, avez-vous trouvé le code dépendant du contexte d'utilisation ?

Si vous avez répondu flex: 1, vous marquez un point. En effet, cette propriété n'a du sens que si le parent direct s'est déclaré comme étant display: flex. Dans la majorité des cas, elle sera ignorée. Pire, dans certains cas, l'affichage de notre <Button /> devra être corrigée pour que le button ne remplisse pas son parent. Retirons alors la propriété flex.

Si vous avez répondu margin-left: 2rem alors vous marquez également un point ! Cette propriété est plus subtile : elle ne dépend pas d'un attribut d'un parent, mais elle influe sur le layout du parent. En d'autres termes, le rendu du button impacte pour son entourage sur la page : il n'est pas agnostique de son contexte. Sans compter que nous souhaiterions sûrement utiliser des buttons sans margin... Retirons également la propriété margin-left.

En revanche, si vous avez répondu padding: 1rem 2rem, alors vous perdez un point ! Le padding influant sur l'intérieur de la box du button, nous considérons qu'elle fait partie du contexte du button.

En fait, il existe plusieurs propriétés à bannir de vos composants afin qu'ils soit réellement réutilisables :

  • les propriétés dépendant d'une autre propriété d'un parent, telles que :
    • les attributs flex
    • les attributs grid
  • les propriétés ayant un impact sur le layout de la page, telles que :
    • margin
    • les tailles en dur (en px ou en rem) pour les height et les width
    • le positionnement (absolute, sticky, fixed)

Je vous propose donc tout simplement de bannir ces propriétés de la définition de vos composants réutilisables. Mais bien évidemment, nous devons parfois appliquer un margin-left à un <Button /> et c'est d'ailleurs le cas dans notre exemple. Alors, comment faire ?

Nos parents savent ce qui est bon pour nous

La réponse à cette question fonctionne dans beaucoup d'autres domaines : il faut déléguer cette responsabilité. Qui sait mieux que le composant dans quel contexte il sera utilisé ? Il s'agit de son composant parent. Nous pouvons alors déporter la logique de positionnement, que j'appellerai désormais de layout dans le parent, et garder uniquement la logique de styling interne dans le composant.

Ainsi, dans notre formulaire de départ, nous allons devoir définir les règles de layout de notre button. Nous pouvons par exemple l'écrire de la façon suivante :

1
2
3
4
5
6
7
8
9
const FormulaireInscriptionLayout = styled.form`
  display: flex;
  flex-direction: row;

  > button {
    margin-left: 2rem;
    flex: 1;
  }
`

Nous utilisons le sélecteur CSS > qui permet de sélectionner nos enfants directs pour lui appliquer les propriétés contextuelles de layout.

En utilisant cette technique, nous avons un autre avantage. Le designer revient nous voir : le design du formulaire change et utiliser un display grid nous faciliterait la tâche. Puisque nous avons délégué la gestion du layout au parent, la migration est plutôt simple : l'ensemble des attributs de grid se retrouvent dans notre composant FormulaireInscriptionLayout, et nous n'avons pas à toucher à notre composant <Button /> existant. Pas de régression possible !

Propriétés CSS contextuelles, propriétés de layout... quoi d'autre ?

Nous avons pour le moment identifié deux catégories de propriétés CSS à bannir de nos composants réutilisables. Existe-t-il d'autres choses qui couplent notre composant à un contexte et qui empêche donc sa réutilisation ?

Le designer revient nous voir : il a réalisé de nouveaux écrans qui comportent également la card blanche du formulaire. Nous jugeons alors pertinent de l'extraire également pour en faire un composant réutilisable. On variabilise rapidement le titre et le contenu de la card pour afficher dynamiquement le titre et le contenu, et on met tout ça dans un fichier à part qui ressemble à ça :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Nouveau composant Card
const Card = ({ title, children }) => (
  <CardLayout>
    <CardHeader>
      <h2>{title}</h2>
    </CardHeader>

    <CardBody>{children}</CardBody>
  </CardLayout>
)

// ...que l'on peut utiliser dans son parent comme ceci
<Card title="Mon titre">
  <p>Mon paragraphe</p>
</Card>

Nous disposons maintenant d'un composant pour afficher le contenu comme ceci :

Composants Réutilisables - Card

A priori, ce composant ne comporte pas de propriétés CSS contextuelles dépendant du parent. Mais comporte-t-il d'autres éléments contextuels ?

L'encapsulation est une excellente propriété des composants. Elle permet par exemple d'abstraire la complexité à réaliser un composant accessible en embarquant des balises HTML fournissant de la sémantique. Néanmoins, certaines balises ne sont-elles pas justement contextuelles ?

C'est par exemple le cas des balises de heading telles de h1, h2, ... Ces balisent dépendent précisément de leur dernière balise parente : il ne doit pas y avoir de h2 sans h1 dans sa hiérarchie.

Dans notre exemple précédent, le code <h2>{title}</h2> est justement contextuel, rendant la card réutilisable uniquement entre un h1 et un h3. Nous avons couplé très fortement le composant et de la sémantique contextuelle.

C'est pourquoi je préconiserais également de limiter l'utilisation de ces balises sémantiques contextuelles dans les composants, et d'utiliser la même solution que pour les propriétés CSS : laisser le parent décider de la sémantique en utilisant l'injection en prop, ou via les slots dans Vue :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Eviter le couplage du h2 dans le composant Card, en injectant la 
// sémantique dans la prop "header"
const Card = ({ header, children }) => (
  <CardLayout>
    <CardHeader>{header}</CardHeader>

    <CardBody>{children}</CardBody>
  </CardLayout>
)

// Dans le parent
<Card header={<h2>Mon titre sémantique</h2>}> // Injection de sémantique depuis le parent
  <p>Mon paragraphe</p>
</Card>

Conclusion

Dans cet article, nous avons mis en avant l'importance de retirer toute propriété dépendant des parents de nos composants afin de pouvoir les réutiliser dans n'importe quel contexte. Il faut pour cela faire attention lors de la conception de nos composants afin d'éliminer :

  • les propriétés de layout dépendant donc du parent
  • la sémantique dépendant du reste de la page

La responsabilité de ces deux points revient donc aux composants parents. En fait, nous venons d'appliquer deux principes fondamentaux du développement logiciel :

  • la séparation des responsabilités : la logique de styling appartenant au composant réutilisable et la logique de layout appartenant au parent.
  • l'inversion de dépendances : le parent injecte la sémantique et la logique de layout

Ces composants de layout sont donc bien souvent spécifiques, mais est-il possible de les rendre réutiliables ? Nous verrons cela dans un prochain article 😃