Accueil / Articles / Concevoir des composants frontend en fonction des responsabilités, et non de la réutilisation

Concevoir des composants frontend en fonction des responsabilités, et non de la réutilisation

Découvrez pourquoi organiser les composants frontend en définissant clairement leurs propriétaires et responsabilités, plutôt que de privilégier une réutilisation maximale du code, permet d’isoler et de maintenir plus facilement les modifications fonctionnelles.

3171 mots

Un frontend devient plus facile à maintenir lorsque les limites des composants sont définies en fonction de responsabilités claires, plutôt que selon la quantité de code qui pourrait théoriquement être partagée.

Le problème avec notre frontend n’a jamais été que les composants individuels étaient devenus trop volumineux. La plupart d’entre eux étaient en réalité très compacts.

Nous disposions d’une bibliothèque solide contenant des boutons, des cartes, des modaux, des éléments de tableau, des contrôles de formulaire, des hooks, des outils API, des schémas partagés, ainsi qu’une organisation rigoureuse des dossiers. En examinant le répertoire, tout semblait bien organisé : la réutilisation était abondante, les doublons évidents rares, et le niveau d’abstraction conférait à la base de code un air de maturité.

Le véritable problème apparaissait chaque fois qu’une fonctionnalité devait être modifiée.

Même une exigence mineure pouvait nous obliger à chercher en même temps dans plusieurs couches non liées entre elles : un composant au niveau de l’écran, un formulaire polyvalent, une logique partagée encapsulée dans un hook, du code de réseau, un dialogue destiné à de nombreux cas d’usage, ainsi qu’un schéma utilisé pour valider les entrées. Un composant initialement conçu pour être partagé parce que deux écrans se ressemblaient accumulait progressivement des indicateurs, des propriétés de rappel, des règles de validation conditionnelles, des layouts alternatifs, et des cas particuliers liés à des workflows pour lesquels il n’était jamais censé être conçu.

Le code était réutilisable, mais les responsabilités étaient dispersées dans tout le système.

Ce qui a finalement simplifié l’interface utilisateur, ce n’a pas été un nouveau schéma de nommage des dossiers, une bibliothèque différente de gestion d’état, ni une limite stricte de nombre de lignes par composant. C’a été un changement dans ce que nous attendions réellement d’une frontière de composant.

Plutôt que de se demander uniquement :

Ce composant peut-il être réutilisé ailleurs ?

Nous avons commencé à nous demander :

Quelle responsabilité ce composant devrait-il assumer ?

Et une deuxième question est rapidement devenue tout aussi cruciale :

Lorsque cette responsabilité doit changer, quelle quantité de code non pertinent est entraînée dans ce changement ?

Cette question a remodelé notre architecture en une hiérarchie simple, où la couche intermédiaire assume le plus de responsabilités.

Les primitives réutilisables se chargeaient uniquement de la présentation. Les composants fonctionnels intégraient la compréhension des flux de travail métier. Les pages et les autres limites de composition déterminaient comment tous les éléments étaient assemblés.

Travailler sur l’interface utilisateur est devenu plus simple, non pas parce que nous avons éliminé toute duplication, mais parce que les modifications au niveau des fonctionnalités sont devenues plus ciblées, et moins de parties du codebase doivent se comprendre mutuellement.

1. La réutilisation n’est pas synonyme de simplicité

« Évitez de dupliquer les composants » est un conseil qui semble généralement correct.

Et souvent, il l’est effectivement.

Lorsque deux écrans partagent le même bouton, champ de saisie ou boîte modale, unifier leur implémentation réduit les incohérences et les travaux de maintenance supplémentaires.

Les problèmes commencent lorsque la ressemblance visuelle est confondue avec une responsabilité partagée.

Imaginez deux fonctionnalités distinctes qui ont toutes les deux besoin d’une boîte de dialogue de confirmation.

La première implémentation pourrait ressembler à ceci :

<ConfirmationDialog
  title="Delete project?"
  onConfirm={deleteProject}
/>

Puis un autre flux de travail a besoin de la même boîte de dialogue, sauf qu’il ne comporte pas de bouton d’annulation.

Un troisième cas nécessite un message d’avertissement personnalisé.

Un autre exige une vérification asynchrone avant que l’utilisateur ne puisse confirmer.

Bientôt, le composant évolue pour ressembler à ceci :

<ConfirmationDialog
  title="Delete project?"
  variant="danger"
  showCancel
  disableConfirm={isDeleting}
  customWarning={warning}
  onBeforeConfirm={validateDeletion}
  onConfirm={deleteProject}
  onSpecialAction={archiveInstead}
  useLegacyLayout={false}
/>

Techniquement, le composant est toujours réutilisé.

Mais d’un point de vue architectural, il a fini par fonctionner comme un langage de configuration.

Chaque nouveau flux de travail demande au composant partagé d’intégrer une variation supplémentaire. Il devient plus flexible, mais cette flexibilité se paie au prix de l’ajout de comportements cachés derrière un ensemble toujours croissant de propriétés.

C’est précisément ici que le coût de l’abstraction diffère du coût de la duplication.

La duplication se manifeste immédiatement : deux composants presque identiques coexistent côte à côte dans le répertoire, visibles clairement pour quiconque lit le code.

La duplication vous coûte quelque chose que vous pouvez voir immédiatement. Choisir la mauvaise abstraction vous coûte quelque chose que vous ne remarquez qu’une fois que les fonctionnalités « similaires » cessent de changer en même temps.

Cette prise de conscience a changé la manière dont nous évaluions la réutilisation.

Le JSX répété ne suscitait plus automatiquement de méfiance. La question qui importait davantage était de savoir si deux morceaux de code partageaient réellement une responsabilité, ou s’ils se ressemblaient simplement à ce moment-là.

2. Nous avons commencé à concevoir des composants autour de la responsabilité

Le changement architectural le plus significatif a été de permettre à l’arbre des composants de refléter la responsabilité réelle plutôt que de chercher uniquement un potentiel de réutilisation.

Prenons un flux de travail de paramètres comme exemple.

Chaque couche de ce flux de travail existe pour une raison bien précise.

UserSettingsPage intègre cette fonctionnalité dans le reste de l’application. Elle peut être consciente du routage, du layout au niveau de la page ou du compte qui est actuellement en cours d’édition.

UserSettingsForm contient les connaissances relatives au flux de travail. Elle sait quels données appartiennent aux paramètres de l’utilisateur, comment les champs sont liés entre eux, ce que signifie une soumission et comment afficher des erreurs à l’utilisateur.

Button, Input et Checkbox n’ont aucune idée que des paramètres d’utilisateur existent même. Ils restent réutilisables précisément parce que leurs responsabilités sont véritablement polyvalentes.

C’est cette couche intermédiaire de fonctionnalités que les équipes ont tendance à perdre en premier.

Dans un frontend qui privilégie excessivement la réutilisation, les développeurs passent souvent directement d’une page à des composants génériques, en contournant toute couche spécifique aux fonctionnalités.

Lorsque cela se produit, le comportement métier n’a plus de place évidente.

Il finit par se retrouver dans la configuration.

Le formulaire générique commence alors à savoir qu’un flux de travail nécessite un champ de nom d’utilisateur tandis qu’un autre ne l’exige pas. Le tableau générique sait quelles actions sont valables pour un type spécifique d’objet de domaine. Un modal partagé adopte des comportements particuliers simplement parce qu’un flux de travail affiche son contenu à l’intérieur d’un modal.

La couche réutilisable absorbe progressivement des connaissances sur le métier pour lesquelles elle n’était pas conçue.

En concevant en fonction des responsabilités, on inverse cette pression.

Les composants spécifiques aux fonctionnalités ont alors la possibilité de comprendre celle qu’ils servent.

Les primitives réutilisables sont délibérément tenues dans l’ignorance de tout élément spécifique à une fonctionnalité.

Ce découplage rend toute l’architecture plus facile à comprendre, car chaque couche n’a besoin de conserver qu’une quantité réduite de contexte.

3. Les composants à usage spécifique ont gagné leur place

L’un des habitudes les plus difficiles à briser était de penser qu’un composant utilisé uniquement en un seul endroit représentait une faille de conception.

Prenons cet exemple :

<OrderCancellationDialog />

Déjà le nom indique que ce composant a été conçu pour un seul flux de travail.

Comparez-le à quelque chose comme :

<ConfirmationDialog />

Ce deuxième nom suggère une plus grande réutilisabilité.

Pourtant, annuler une commande peut finir par nécessiter bien plus qu’une simple confirmation.

L’utilisateur pourrait devoir choisir une raison de cancellation. L’écran pourrait afficher les détails du remboursement. Certains commandes pourraient ne pas être éligibles à la cancellation. Les vérifications de permissions pourraient varier. Le texte d’avertissement pourrait changer en fonction du statut de traitement. L’action elle-même pourrait disposer de son propre mécanisme de gestion des erreurs asynchrones.

En regroupant tout cela dans un ConfirmationDialog générique, ce composant partagé devient progressivement un expert de ce que signifie réellement annuler une commande.

Une structure plus adaptée ressemble généralement à ceci :

OrderCancellationDialog prend en charge l’ensemble du flux de travail.

Il peut disposer d’informations sur les permissions, les raisons de cancellation, le statut asynchrone, le texte de remboursement et les états d’erreur, car tout cela fait naturellement partie intégrante de ce processus.

Pendant ce temps, les éléments de base de niveau inférieur restent réutilisables précisément parce qu’ils ne possèdent aucune connaissance concernant les commandes.

Cette séparation a changé le nombre de décisions relatives aux composants qui étaient prises.

Il n’était plus nécessaire de justifier l’existence d’un composant en comptant le nombre d’endroits qui l’importaient.

La réutilisabilité n’est pas une exigence pour tous les composants. Certains composants existent uniquement pour offrir un cadre adapté à une logique métier spécifique.

Ce type de clarté locale l’emporte souvent sur le coût lié à l’introduction d’un second flux de travail non pertinent dans une abstraction partagée, simplement parce que les API de surface semblent similaires.

4. La logique métier reste à l’écart des composants d’affichage

Le même problème de responsabilités est réapparu en ce qui concerne le traitement des données et les aspects d’infrastructure.

Un composant qui commence par être purement visuel peut progressivement prendre en charge des responsabilités telles que la récupération de données, la lecture des paramètres URL, l’exécution de mutations, le déclenchement de notifications, la gestion de la navigation, la gestion du cache et le suivi de l’état du flux de travail.

Imaginez une ProjectTable qui finit par gérer tout cela :

ProjectTable
 ├── fetch projects
 ├── read query parameters
 ├── filter projects
 ├── manage loading state
 ├── render rows
 ├── delete projects
 ├── show notifications
 └── navigate after actions

Le nom suggère encore « table ».

Mais le composant comprend désormais une grande partie des fonctionnalités associées.

Cela rend difficile sa réutilisation ailleurs, car en réutilisant cette table, on emporte avec soi des hypothèses concernant la récupération de données, les mutations, la navigation, le cache et les effets secondaires.

Une structure organisée autour des responsabilités pourrait ressembler à ceci :

Le code au niveau des fonctionnalités détermine à quoi ressemble le « chargement », comment les erreurs sont gérées, ce que fait réellement l’opération de suppression, quels filtres s’appliquent à ce flux de travail particulier, et si l’utilisateur doit être redirigé par la suite.

ProjectTable lui-même se concentre uniquement sur l’affichage des données du projet et le suivi des actions de l’utilisateur.

Cela ne signifie pas pour autant que les composants de présentation doivent être dépourvus de toute logique.

En faisant de cela une règle stricte, on crée simplement le même problème sous une autre forme. Une table peut légitimement conserver un état d’interaction local, comme les lignes qui sont dépliées ou les colonnes visibles, car cet état appartient réellement à la table.

L’idée directrice plus pertinente est :

Ne laissez pas les connaissances au niveau de l’infrastructure se propager plus bas dans l’arborescence des composants que ce dont la fonctionnalité a réellement besoin.

L’objectif n’est pas d’éliminer complètement la logique des composants.

Il s’agit plutôt de placer cette logique près de la responsabilité qui lui confère son sens.

5. Assembler des éléments plutôt que de modifier des options

L’un des changements les plus marquants a surgi lorsque nous avons cessé de répondre à chaque nouvelle exigence en ajoutant une autre propriété.

Les composants génériques ont tendance à s’enrichir au fil des configurations.

Une table peut commencer de manière modeste :

<DataTable rows={projects} columns={columns} />

Puis les exigences s’accumulent :

<DataTable
  rows={projects}
  columns={columns}
  selectable
  sortable
  paginated
  editableRows
  showBulkActions
  enableExport
  customToolbar={toolbar}
  rowActions={rowActions}
  emptyState={emptyState}
  onSelectionChange={handleSelection}
/>

Aucune de ces propriétés n’est en soi irrationnelle.

Les problèmes commencent lorsque le composant doit suivre quelles combinaisons de propriétés sont valides.

Les lignes peuvent-elles être à la fois modifiables et sélectionnables en même temps ?

Faut-il afficher des actions en masse si l’export a été désactivé ?

Une barre d’outils personnalisée remplace-t-elle la barre par défaut, ou reste-t-elle à côté d’elle ?

La pagination est-elle gérée du côté client ou par le serveur ?

Chaque booléen et fonction de rappel ajoutés augmente le nombre d’états que le composant générique doit prendre en compte.

La composition reporte une partie de cette responsabilité sur l’appelant :

<Table>
  <TableToolbar>
    <ProjectFilters />
    <ExportButton />
  </TableToolbar>
<ProjectRows projects={projects} />
  <Pagination />
</Table>

Désormais, c’est le parent qui décide quels éléments ce flux de travail particulier nécessite réellement.

Les primitives de base des tableaux n’ont pas besoin d’intégrer toutes les variations spécifiques aux produits que l’application pourrait un jour inventer.

La configuration exige qu’un composant anticipe toutes les combinaisons possibles. La composition permet à l’appelant de créer uniquement la combinaison dont il a réellement besoin.

La composition ne empêche pas automatiquement quelqu’un de créer une combinaison sans sens. Un appelant peut toujours produire une combinaison défectueuse.

Ce qui change, c’est la nature des modifications : le composant partagé n’a plus besoin d’encoder lui-même chaque variation spécifique à l’application.

Il est tout à fait acceptable de conserver certaines configurations. Un Button réutilisable, par exemple, devrait supporter des options cohérentes telles que la taille, l’état désactivé ou l’accentuation visuelle.

La vraie question à se poser est de savoir si une propriété donnée représente simplement une autre variation de la même responsabilité fondamentale, ou si elle pousse discrètement un composant à gérer plusieurs tâches non liées en même temps.

6. Une responsabilité claire rend les décisions relatives à l’état plus simples

La plupart des problèmes d’état côté frontend ressemblent, en apparence, à des problèmes liés aux outils.

La discussion se transforme généralement en un débat sur les mécanismes appropriés :

Faut-il que cela soit stocké dans l’état du composant, dans Context, Zustand, Redux, dans l’URL ou dans un cache serveur ?

Cependant, en pratique, beaucoup de ces problèmes se résolvent d’eux-mêmes une fois que l’on sait qui est réellement responsable du comportement.

L’état a tendance à s’élever dès que la responsabilité n’est pas claire :

Un modal commence avec un état qui se trouve à l’intérieur de lui.

Puis un autre composant a également besoin de le déclencher, ce qui fait que l’état est transféré ailleurs.

Plus tard, une barre d’outils située loin dans l’arborescence a aussi besoin d’y accéder, ce qui le place dans le Contexte.

Bientôt, un seul stockage global contient des indicateurs de visibilité des modaux, des brouillons de formulaires inachevés, des paramètres de filtrage de tableaux, des réponses API mémorisées, des sélections de onglets actifs, ainsi que divers détails provenant de fonctionnalités sans rapport les unes avec les autres.

À ce stade, on accuse généralement la bibliothèque de gestion d’état pour ce désordre.

Mais le véritable problème n’est généralement pas l’outil, c’est la responsabilité attribuée.

Lorsque les limites des composants reflètent déjà les responsabilités réelles, l’état a naturellement une place logique où se trouver.

Tout ce que l’utilisateur tape actuellement dans un champ peut rester local à ce champ.

Un flux de suppression en plusieurs étapes doit faire partie de la fonctionnalité de suppression elle-même.

Tout ce qui est récupéré depuis le serveur doit être placé là où votre application gère les aspects liés à l’état du serveur.

Un filtre qui doit persister entre différentes navigations ou être partageable via un lien devrait probablement se trouver dans l’URL.

L’état qui est réellement partagé entre de nombreuses parties non liées de l’application peut justifier un responsable plus large et centralisé.

Rien de tout cela ne fait disparaître les décisions complexes concernant l’état, mais il vous fournit un cadre pour les prendre.

Lorsque les limites des composants reflètent bien la responsabilité réelle, il devient beaucoup plus simple de décider où l’état doit être stocké.

Cet modèle mental a été plus bénéfique pour l’équipe que le choix d’une bibliothèque d’états « correcte » aurait jamais pu l’être.

7. Parfois, la duplication était la meilleure solution

La partie la plus difficile à accepter dans cette approche était le fait qu’une certaine quantité de duplication soit acceptable, voire souhaitable.

Imaginez deux formulaires qui ressemblent presque identiquement, partageant environ 80 % de leur balisage et de leur structure.

L’instinct est de les fusionner immédiatement en un seul composant partagé.

Mais ces 20 % restants pourraient contenir une logique métier complètement différente.

Peut-être que l’un des formulaires se valide différemment.

Peut-être que soumettre l’un déclenche une chaîne d’effets différente de celle de l’autre.

L’un ne fera peut-être qu’enregistrer un brouillon.

L’autre pourrait déclencher quelque chose d’irréversible.

Un des formulaires risque de devenir de plus en plus complexe avec le temps, tandis que l’autre devrait rester minimaliste.

Tenter de forcer l’intégration des deux dans une seule implémentation partagée a tendance à produire quelque chose qui ressemble à un labyrinthe de conditions et de cas particuliers intégrés dans un composant censé être « réutilisable ».

À ce stade, on a remplacé la duplication du JSX par une charge cognitive accrue. Quiconque travaille sur l’un ou l’autre flux de travail doit d’abord comprendre en reverse-engineering comment le composant partagé protège l’autre avant de pouvoir apporter des modifications en toute sécurité.

Dans de tels cas, il est souvent préférable de conserver deux éléments distincts et nommés séparément :

FeatureAForm
FeatureBForm

même si une partie du code sous-jacent se répète.

Cela ne constitue pas une argumentation en faveur de la duplication en tant que vertu en général.

Lorsqu’une responsabilité véritablement partagée devient évidente et stable, on peut l’extraire. Les deux versions pourront finalement partager les mêmes primitives d’entrée, des outils de validation, des enveloppes de mise en page ou d’autres éléments indépendants du domaine spécifique.

La véritable différence réside dans le timing, et non dans les principes.

Au lieu de recourir à l’abstraction dès que deux éléments se ressemblent, il était plus logique d’attendre que des limites communes se soient réellement affirmées avec le temps.

Une règle empirique en a découlé :

Mieux vaut dupliquer du code simple que de partager une logique basée sur des hypothèses qui évoluent encore.

Un code simplement dupliqué est facile à identifier et reste confiné en un seul endroit.

En revanche, une abstraction créée prématurément peut regrouper discrètement plusieurs hypothèses spécifiques à une fonction sous un nom trompeusement ordonné, cachant ainsi la complexité même que l’on tentait d’éliminer.

8. De quoi il s’agissait vraiment : garder les changements locaux

Finalement, il est devenu clair que toute cette approche ne portait jamais réellement sur la taille ou la reproductibilité d’un composant.

Il s’agissait de la manière dont les changements peuvent être localisés.

La question à se poser à chaque fois était :

Lorsque nous modifions une fonctionnalité, quelle quantité de code non lié devons-nous d’abord comprendre ?

Un design comportant plus de composants partagés et généralisés peut techniquement présenter moins de lignes redondantes qu’un design avec davantage d’éléments spécifiques à une fonctionnalité.

Cependant, cela peut encore obliger un développeur à mémoriser une grande partie de l’application entière simplement pour effectuer une modification sans risque.

Ce type de coût apparaît rarement dans les métriques de réutilisation.

On peut avoir un composant magnifiquement réutilisable qui crée néanmoins des barrières terribles pour effectuer des modifications.

Inversement, un composant conçu pour une seule fonctionnalité et utilisé en un seul endroit peut néanmoins améliorer la maintenabilité du codebase, simplement parce qu’il rend ce flux de travail autonome et facile à comprendre.

Ce fut finalement la formulation la plus percutante de toute l’idée :

Définissez les limites des composants en fonction de la logique locale, et non pour maximiser leur réutilisation.

Ce principe unique relie tout le reste entre eux.

Les primitives réutilisables trouvent leur place parce que les modèles de présentation se reproduisent effectivement à travers tout un codebase.

Les composants spécifiques à une fonctionnalité restent tels car les flux de travail métier évoluent pour des raisons qui rarement coïncident.

La composition empêche les composants partagés d’avoir à maîtriser chaque variation métier possible.

L’état reste associé au comportement qui en est réellement responsable.

La duplication devient acceptable précisément lorsque le partage de code risquerait de lier des hypothèses qui s’éloignent déjà d’elles-mêmes.

Le frontend devient plus simple non pas parce que chaque composant est devenu plus petit ou plus réutilisable, mais parce que chacun demande moins à la personne qui le lit.

La bonne frontière entre composants est celle que l’on peut expliquer lorsque quelque chose change

Il est tentant d’évaluer une base de code frontend en se basant sur des signes superficiels : une forte réutilisation des composants, une duplication minimale, des dossiers bien organisés, de petits tailles de fichiers. Ces éléments ne sont pas sans importance, mais ils disent étonnamment peu sur ce qui se passe réellement dès qu’une exigence change.

C’est finalement ce test qui s’est avéré bien plus utile à appliquer.

Où ce comportement appartient-il réellement ?

Quel composant est responsable de la compréhension de cette règle métier ?

Où ce morceau particulier d’état doit-il être placé ?

Si une exigence change, quels fichiers devraient logiquement être regroupés ?

Quelles parties de l’interface utilisateur sont véritablement réutilisables, et lesquelles doivent rester spécifiques à une fonctionnalité particulière ?

Le modèle qui a finalement simplifié cet interface n’était pas une technique d’abstraction nouvelle et ingénieuse.

Cela revenait simplement à réduire la quantité d’éléments à gérer pour chaque couche du code.

Les primitives réutilisables s’occupaient de la présentation.

Les composants fonctionnels géraient les flux de travail.

Les limites de composition déterminaient la manière dont les fonctionnalités s’assemblent.

Quant aux composants spécifiques à l’activité métier, on leur a permis de rester exactement ce qu’ils étaient : spécifiques.

Le code résultant n’avait pas nécessairement moins de composants, ni le minimum théorique de doublons.

Ce qui en ressortait, c’était une base de code permettant à un développeur de travailler sur une seule fonctionnalité sans avoir d’abord à mémoriser l’architecture de tout l’interface utilisateur.

Ce fut en fait une définition bien plus pratique de la simplicité que le simple comptage des composants ou des lignes dupliquées.

D’après votre expérience, est-ce que davantage de composants réutilisables ou des limites plus claires entre les composants existants ont davantage contribué à rendre votre interface utilisateur maintenable ?

Lectures complémentaires

  • Réduire React Prop-Drilling et les God Components — Découvrez sept modèles de refactoring concrets pour décomposer les composants React encombrants en isolant l’état, la récupération de données, les permissions et la logique de chargement, plutôt que de se contenter de diviser les fichiers.
  • Une comparaison pratique des patterns de structure de dossier React — Explique les structures de projets React basées sur des fonctionnalités, des couches ou du domaine, et offre des conseils pour choisir celle qui convient le mieux à l’évolution de votre application.
  • Concevoir pour le cache Retour/Avant : éligibilité, restauration et état — Découvrez comment le cache Retour/Avant du navigateur restaure des pages entières, ce qui l’empêche silencieusement de fonctionner, et comment gérer l’état restauré avec pageShow et pageHide.