L'essentiel du travail sur un design system, c'est de dire non
TL;DR
- Un design system qu'une douzaine d'apps utilisent est une API publique. Supprimer quatre props d'un composant qui n'avait jamais été publié n'a rien coûté. Après une release, chaque flag demande un alias déprécié et un cycle de dépréciation complet. Donc l'essentiel du travail, c'est de dire non en review, avant le merge.
- Voici huit règles que je fais respecter en review, chacune avec le cas qui est derrière. Entre autres : composer des sous-composants au lieu d'empiler des props booléennes, lire le code d'un composant avant de construire dessus, et corriger un problème dans la couche qui le cause.
- Certains de mes propres arguments étaient plus faibles qu'ils n'en avaient l'air. "Quatre booléens, c'est seize combinaisons à tester", c'est surtout de la rhétorique. Un reviewer avait raison sur deux coûts de la composition que je recommande. Et aujourd'hui, je contesterais mon propre ancien slogan, "quand un gate a un angle mort, déplace le code, pas le gate".
- Mise à jour de novembre 2026 : une modification qui destructurait une prop déclarée sur le mauvais type de props a passé le lint, les tests et le build, et je lisais ces trois checks verts comme "ça compile". Un gate (un contrôle qu'une modification doit passer avant d'être mergée) que tu n'as jamais vu échouer n'est pas un gate.
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 :
- Props booléennes (flags). Un seul composant avec une prop par bloc, comme
showHintoushowActions. C'est le composant qui décide de l'ordre des blocs. - Compound components (le pattern compound). Le composant racine (
<Panel>) prend la valeur et les callbacks. Chaque bloc est une partie attachée à la racine (Panel.Body,Panel.Options). Le consommateur écrit les parties qu'il veut comme enfants, dans l'ordre qu'il veut. Dans le Panel, la racine partage son état avec ses parties via un contexte (ce n'est pas le cas de tous les compound components).
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 :
Object.assign(Component, { ...subs }): la racine est un composant qu'on peut rendre, avec les parties attachées comme propriétés.- Un objet littéral pur : un simple objet qui ne fait que contenir les parties et qui n'est pas lui-même un composant.
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 :
- Il ne sait pas filtrer du tout.
enableColumnFilters: falseest codé en dur et aucun modèle de lignes filtrées n'est enregistré : la table ne met jamais en place d'étape de filtrage. Sur les trois capacités qui motivaient le choix, il en fournit deux. - Pas de prop de tri contrôlée, seulement une valeur initiale plus une notification de changement, donc la page ne peut pas fixer l'ordre de tri comme
valuefixe la valeur d'un input. La ligne d'en-têtes de colonnes devient alors la seule affordance de tri (le seul endroit où un utilisateur peut trier), ce qui met un en-tête de table au-dessus d'une grille de cartes dont les colonnes ne s'alignent pas dessus. Cache l'en-tête et la grille ne peut plus être triée. - Le mode grille exige la virtualisation et une hauteur fixe en pixels. La virtualisation, c'est ne monter que les lignes visibles à l'écran. Ici, les consommateurs vivent dans des panneaux qui grandissent avec le contenu.
- Sur une seule colonne, le mode grille retombe en silence sur des lignes de table. Ce piège est présent dans la story de référence de la librairie elle-même (l'exemple que la librairie publie pour le composant).
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 :
- Le découpage se justifie tout seul. La logique stateful et la mise en page n'ont pas le même poids et vont à des endroits différents. Cet argument n'a besoin d'aucune référence à la couverture.
- Le trou dans le gate est un vrai deuxième problème, avec son propre ticket. Une dizaine de templates existants ne sont pas couverts et ne l'ont jamais été. "Élargir le glob ferait échouer le build aujourd'hui", c'est un argument sur le coût de migration, pas sur la place du code.
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 :
- le
observedu mock de ResizeObserver est un simplejest.fn()qui n'appelle jamais le callback ; - le
getBoundingClientRect()de jsdom ne renvoie que des zéros ; - donc la librairie de rendu abandonne avant d'émettre le SVG.
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
- Traite une librairie partagée comme une API dont on ne peut pas forcer les utilisateurs à migrer. Rien de tout ça n'est exotique : c'est surtout accepter d'avoir la conversation gênante avant le merge plutôt que le ticket de migration après la release.
- Commence par l'argument qui tient (l'ordre), pas par celui qui sonne bien (seize combinaisons), et dis ce que ton choix coûte : la composition n'est pas gratuite.
- La réputation d'un composant n'est pas son contrat, et le voisin que tu copies est peut-être dans la minorité. Ouvre le code et compte.
- Une règle sans limite n'est pas une règle. Corrige une contrainte dans la couche qui la cause, et dis où la couche change.
- Ne laisse pas un gate justifier l'architecture. Défends la place du code sur le fond, et remonte l'angle mort du gate comme un problème à part.
- Chaque propriété que tu as décidé de garder a besoin d'un test qui échoue quand quelqu'un la retire. Et si je n'arrive pas à faire échouer un gate exprès, je ne sais pas ce qu'il vérifie (voir la mise à jour de novembre 2026).
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 :
- Derrière le seuil de couverture à 100 % se cachaient plus de 500 marqueurs d'exclusion de couverture.
- Plus de 200 erreurs de type dans les tests et les stories n'étaient vérifiées par aucun gate.
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 :
- Introduis un cas qu'il doit attraper. Pour le gate de contraste, c'est une association volontairement mauvaise à 1,93:1.
- Regarde la suite passer au rouge.
- 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.