Clément Farkas DauduEN

L'essentiel du travail sur un design system, c'est de dire non

TL;DR

Pourquoi un changement coûte cher une fois la librairie sortie

Un design system consommé par une douzaine d'apps n'est pas un dossier de composants. C'est une API publique que tu ne peux pas refactorer unilatéralement, et l'équipe qui la maintient n'est pas celle qui paie pour tes erreurs.

Le tournant, c'est la première release, quand les apps peuvent commencer à dépendre de ce que tu as publié. Après, tu ne peux plus simplement supprimer une prop. Il lui faut un alias déprécié (l'ancien nom continue de marcher, marqué comme déprécié) et un cycle de dépréciation complet (le temps que chaque app arrête de l'utiliser avant que la prop disparaisse), parce qu'on ne peut pas forcer les apps qui l'utilisent à migrer. Les règles 1 et 6 chiffrent ce coût :

Le même changement Avant la première release Après
Supprimer quatre props booléennes (règle 1) N'a rien coûté : le composant n'avait jamais été publié Un alias déprécié pour chaque flag et un cycle de dépréciation complet
Trancher entre compound et flags (règle 1) Une conversation de cinq minutes Un trimestre de migration
Supprimer un composant qui marche et dont le type de props est exporté (règle 6) Un rm Un cycle de dépréciation

Tout ce qui suit est une règle que je fais vraiment respecter en review, avec le cas qui l'a produite. Rien de tout ça ne porte sur le design visuel.

Concevoir l'API d'un composant (règles 1 à 3)

Compound components contre props booléennes, en deux minutes

Les trois premières règles portent sur les composants faits de plusieurs parties. Il y a deux façons de laisser un consommateur activer et désactiver les blocs d'un composant :

flowchart TD
  Root["Racine Panel : value, onChange, onCommit"] --> Ctx["Contexte partagé avec les parties"]
  Ctx --> Body["Panel.Body"]
  Ctx --> Options["Panel.Options"]
  Ctx --> Toggle["Panel.Toggle"]
  Ctx --> Actions["Panel.Actions"]
  Actions -. "s'enregistre au montage" .-> Ctx

1. La composition plutôt que les props, à trancher avant la première release

Un petit panneau composite est arrivé en review avec quatre booléens : showHint, showOptions, showToggle, showActions, qui affichaient les blocs dans un ordre fixe. Il a finalement été mergé comme ça :

<Panel value={value} onChange={preview} onCommit={save}>
  <Panel.Body />
  <Panel.Options />   {/* move this line to reorder the blocks */}
  <Panel.Toggle>Set as default</Panel.Toggle>
  <Panel.Actions />
</Panel>

L'argument qui tient : l'ordre. Aucune de ces combinaisons de flags ne permet à un consommateur de mettre les options au-dessus du body. Avec la composition, c'est une ligne à déplacer. Celui-là, il n'y a rien à lui opposer.

Le deuxième argument : la cohérence, et il est plus solide qu'il n'en a l'air. Plus de 30 composants de la librairie utilisent déjà le pattern compound, dont 17 qui partagent leur état via un contexte. Le panneau piloté par des flags était l'exception. Dans une librairie, suivre la forme établie vaut plus que n'importe quelle API un peu plus agréable prise isolément.

L'argument que j'ai rétrogradé. Celui que j'aurais avancé il y a un an, c'est "quatre booléens, c'est seize combinaisons à tester". Ça sonne bien et c'est surtout de la rhétorique : personne ne gère les 2⁴ cas, il y a trois ou quatre configurations réelles et quatre tests de snapshot les couvrent. Ne commence pas par le chiffre.

Deux choses que je dois concéder, parce qu'un reviewer a insisté dessus et qu'il avait raison.

D'abord, les parties compound ne donnent aucune garantie à la compilation qu'une partie obligatoire est présente. Un consommateur peut rendre <Panel> sans .Body et obtenir un composant cassé en silence. Quatre booléens avec des valeurs par défaut raisonnables ne peuvent pas produire ça. Il n'y a pas de correctif propre, juste un warning au runtime.

Ensuite, la règle de commit (est-ce qu'une modification est enregistrée tout de suite ou attend une étape de validation) devient plus complexe, pas plus simple. Avec les flags, la racine lisait showActions pour savoir si une modification est enregistrée immédiatement. Une fois que la composition s'en charge, la racine ne peut plus voir s'il existe une étape de validation, donc .Actions s'enregistre auprès du contexte au montage.

Utilise un compteur, pas un booléen. Un remount qui monte la nouvelle partie avant de démonter l'ancienne ne doit jamais se lire, même un instant, comme "pas d'étape de validation" : un booléen serait réinitialisé par le départ de l'ancienne partie alors que la nouvelle est là. Un compteur survit aussi au +1 / -1 / +1 du StrictMode (il monte, démonte puis remonte la partie).

Donc le compromis honnête, c'est :

Ce que tu gagnes Ce que tu paies
L'ordre et la cohérence Un protocole d'enregistrement et une garantie à la compilation perdue

Je referais ce compromis, mais il n'est pas gratuit et je ne devrais pas faire comme s'il l'était.

Fais-le avant de publier. Supprimer ces quatre props n'a rien coûté parce que le composant n'avait jamais été publié. Après une release, chaque flag demande un alias déprécié et un cycle de dépréciation complet. Compound ou flags, c'est une conversation de cinq minutes avant la première release et un trimestre de migration après.

2. Le brief design décrit la mise en page, pas l'API

Ces quatre booléens venaient tout droit du brief (ce que le design avait demandé), qui disait "toggles indépendants, ordre fixe".

C'est l'UX qui décrit le résultat visuel. Ça ne contraint pas le contrat technique. Un ordre visuel fixe n'impose pas un ordre structurel fixe, et les deux sont trivialement compatibles ici : les consommateurs écrivent les blocs dans l'ordre que montre le design.

Le design décide de ce à quoi ça ressemble. Il ne décide pas de la forme de l'API. Ça vaut le coup de le dire à voix haute en review, calmement, parce qu'on cite le brief comme s'il réglait la question.

3. Suivre le pattern maison, pas la minorité

Une nouvelle famille de composants (une racine et ses parties, comme le panneau) est d'abord sortie sous forme de simple namespace objet, copiée sur un composant existant qui fait exactement ça. Les chiffres ci-dessous comparent deux façons de regrouper les parties d'une famille :

Le composant qui a servi de modèle est minoritaire. À l'époque :

Comment les parties sont regroupées Composants
Object.assign(Component, { ...subs }) 31
Objet littéral pur 6

On en est à 35 aujourd'hui, les chiffres bougent.

Copier ses voisins, c'est normalement le bon réflexe dans une grosse codebase. C'est aussi comme ça qu'on hérite de la seule exception au lieu de la convention.

C'est donc devenu Widget = Object.assign(WidgetRoot, { A, B, Legend }), sans Widget.Container redondant, parce que faire de la racine le conteneur ne coûte rien : le contexte de surbrillance partagé vaut null à l'extérieur (une partie hors de la racine n'a pas de contexte), et les parties s'affichaient déjà toutes seules.

La règle : avant de copier un pattern sur un composant voisin, compte combien de composants font l'inverse.

Une grille de cartes, trois règles (4 à 6)

Les règles 4 à 6 viennent du même chantier : une grille de cartes qui avait besoin de tri, de filtrage et de sélection.

4. Lire le code avant de choisir le composant

La réponse évidente, c'était le composant table de la librairie en mode grille. Le lire a tué cette idée. D'abord, quatre manques fonctionnels :

Ensuite, la vraie pièce à conviction, un défaut de conception :

Le template de ligne est découvert par duck-typing. Le parent cherche parmi ses enfants celui qui a une prop row ('row' in child.props). Ça oblige chaque site d'appel à écrire row={undefined as unknown as Row<T>} juste pour être reconnu : passer undefined et dire au système de types que c'est une ligne. Un mensonge par cast, visible dans le fichier de story de la librairie elle-même. Et comme la découverte teste typeof child.type === 'function', le template ne peut jamais être forwardRef (un composant forwardRef n'est pas une simple fonction).

Les quatre premiers sont des manques dont on peut discuter ; une API publique qui oblige les consommateurs à mentir au système de types pour être vus est un défaut de conception qui, lui, ne se discute pas.

La réponse a été une table headless (la logique de table sans UI propre) portée par la page, qui passe au design system des lignes déjà triées et filtrées. Pas une nouvelle API, un pattern documenté avec une story de référence et un test.

La règle : la réputation d'un composant n'est pas son contrat. Ouvre-le.

5. La vraie contrainte est rarement celle dont on débat

L'argument qui revenait toujours pour faire passer cette grille par le composant table, c'était la virtualisation, pour tenir la charge.

À quelques centaines de cartes, le goulot d'étranglement (ce qui limitait vraiment la page) n'était pas le DOM. C'était le composant image, qui récupère chaque source au montage et ne peut pas être rendu lazy, par construction. La virtualisation ne fait que masquer ça en ne montant pas les lignes hors écran. Le bon correctif est dans le composant image, et tant qu'il n'est pas livré, aucune implémentation de grille ne change le nombre de requêtes.

Là où ça s'inverse, parce qu'une règle sans limite n'est pas une règle :

Cartes sur un écran Ce qui limite Quoi faire
Quelques centaines Le composant image, pas le DOM Corriger le composant image ; la virtualisation ne fait que le masquer
Des milliers, et le composant image précharge encore tout dès le départ Le nombre de requêtes, mais aussi le nombre de nœuds DOM et la mémoire La virtualisation du mode grille devient la façon pragmatique de contenir le problème

La règle vaut pour des centaines, pas pour des milliers.

Corriger le symptôme visible dans la mauvaise couche, c'est comme ça qu'un design system accumule des composants que personne ne peut retirer. Mais dis où la couche change.

6. Supprimer avant la release coûte un fichier, après, un cycle

Pendant l'exploration de cette grille, un composant pont a été construit, testé, et on a prouvé au rendu qu'il marchait. Puis supprimé avant le merge.

Il marchait. C'était aussi une violation connue de la forme d'API, livrée dans la surface publique (ce que la librairie exporte pour que les apps l'utilisent) pour une piste que personne n'avait choisie. Son type de props était déjà exporté, donc le retirer après une release aurait coûté un cycle de dépréciation. Avant la release, ça a coûté un rm.

Avoir raison sur la direction ne suffit pas à justifier de livrer le truc.

Quand un check ne couvre pas ce que tu crois (règles 7 et 8)

7. L'endroit où vit un fichier décide des gates qu'il passe, et c'est aussi un bug

Un gate, c'est un contrôle qu'une modification doit passer avant d'être mergée. La mesure de couverture (quelle part du code les tests exécutent) était limitée à src/components/** avec un seuil de 100 %, et le gate de review "ce nouveau composant est-il justifié" se basait sur le même dossier. Donc src/templates/ échappait aux deux :

Gate src/components/** src/templates/
Couverture, seuil de 100 % Oui Non
Review : "ce nouveau composant est-il justifié" Oui Non

Un template obtient 100 % de rien.

Ce qui inverse parfaitement les incitations : une variante triviale de badge dans components/ passe les deux gates, et le composite le plus chargé en branches de la librairie n'en passe aucun. Facile de tomber dedans sans le vouloir, parce qu'un brief qui dit "nouveau Template" pointe tout droit vers templates/.

On a découpé selon le poids : la partie stateful et pleine de branches va dans components/, seule la mise en page au niveau de la page reste dans templates/.

Le slogan que j'ai retiré. Je résumais ça par "quand un gate a un angle mort, déplace le code, pas le gate". Je ne pense pas que ça se généralise, et je contredirais aujourd'hui quiconque me le ressortirait. Pris comme une règle, ça dit remodèle la codebase autour de ta config de CI, et en quelques années ça te donne des arborescences qui encodent l'historique de l'outillage au lieu de l'architecture.

Il y a ici deux faits distincts, et ils méritent d'être traités séparément :

J'ai fait le premier et ouvert un ticket pour le second. Dis les deux, ne laisse pas le gate servir de justification à l'architecture.

8. Verrouiller ce qu'on suppose avec un test

Les deux parties de cette famille ont un test qui vérifie qu'elles s'affichent sans ancêtre <Chart>, en comptant les marques rendues (les formes que le graphique dessine) plutôt qu'en vérifiant que la surface existe.

La raison est précise et mérite d'être bien dite, parce que je l'ai expliquée de travers la première fois. Avec notre setup Jest, un composant qui mesure sa taille ne rend presque rien :

Mesuré sur l'un d'eux : 106 octets de DOM, pas le moindre <svg>, et rien dans les logs. Comment chaque assertion se comporte dessus :

Assertion Sur un composant qui ne peut pas dessiner
La surface existe Échoue bruyamment, ce qui est bien
"Le rendu passe sans lever d'erreur" Passe sans rien prouver
Un conteneur non vide Passe sans rien prouver
Un snapshot du div de 106 octets Passe sans rien prouver
Compter les marques rendues La seule chose qui prouve qu'il a dessiné

Les assertions dangereuses sont les plus faibles. Compte les marques.

Note que c'est une propriété de notre setup de test, pas de jsdom. Avec un polyfill de ResizeObserver qui se déclenche vraiment, tu ne le reproduiras pas. Vérifie ton propre mock avant de te croire à l'abri.

La règle générale : chaque propriété que tu as décidé de garder (un wrapper optionnel, un usage autonome, un alias rétrocompatible) a besoin d'un test qui échoue quand quelqu'un la retire. Sinon ce n'est pas une décision, c'est une coïncidence.

Ce que j'en retiens

Pas d'accord avec un de ces points ? J'aimerais sincèrement entendre le contre-exemple, surtout sur la 4 et la 7.

Mise à jour, novembre 2026

9. Un gate que tu n'as jamais vu échouer n'est pas un gate. En septembre, une modification a destructuré une prop qui avait été déclarée sur le mauvais type de props. Le lint est passé, toute la suite Jest est passée, le build est passé. La CI n'est passée au rouge que dans le build de la doc : les tests compilent un fichier à la fois et le build ne fait que transpiler. Je lisais trois gates verts comme "ça compile". Depuis fin octobre, un type check dédié tourne sur chaque pull request.

L'audit d'architecture d'octobre a retrouvé le même schéma deux fois de plus :

Un seuil dont on peut se dispenser ligne par ligne est un chiffre, pas un gate.

J'ai vu la même chose sur un design system perso que je maintiens. Son gate d'accessibilité échoue sur les violations de contraste, et il avait l'air de couvrir tous les thèmes. Il scannait chaque story une seule fois, en thème clair et avec le skin par défaut, parce que c'étaient les valeurs par défaut du test runner. Puis je l'ai fait tourner sur chaque story dans les quatre modes (clair et sombre, chacun avec deux skins) :

Avant Maintenant
Deux démos avec une couleur de lien personnalisée sur une surface sombre La CI laissait passer les deux Échec de contraste à 1,64:1 et 1,25:1
Résultats que le checker marque "needs review", quand il ne sait pas trancher Passaient en silence Échouent
Exécution complète des tests 16 secondes 37 secondes, environ 2,3 fois plus lente

Quatre modes, c'est quatre fois plus de tests de stories. Ça vaut le coup.

Avant de faire confiance à un gate :

  1. Introduis un cas qu'il doit attraper. Pour le gate de contraste, c'est une association volontairement mauvaise à 1,93:1.
  2. Regarde la suite passer au rouge.
  3. Fais un revert.

Ce cas doit être remis à chaque fois que la plomberie du gate change. Si je n'arrive pas à faire échouer un gate exprès, je ne sais pas ce qu'il vérifie.