Réduire au strict minimum les pratiques de « prop-drilling » et les composants « God Components » dans React
Apprenez sept modèles concrets de refactoring pour décomposer les composants React surchargés 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.
Pendant un certain temps, la complexité de React a été évaluée uniquement en fonction du nombre de lignes. Dès qu’un composant dépassait trois cents lignes, la barre d’outils était transférée dans un fichier distinct. Lorsqu’il atteignait quatre cents lignes, le tableau était également extrait. Le fichier de niveau supérieur devenait plus petit, mais la fonctionnalité elle-même restait difficile à comprendre. Le composant parent continuait de contenir toutes les requêtes réseau, tous les modaux, une multitude de champs de formulaire et leur état de validation, des vérifications concernant les actions autorisées à l’utilisateur actuel, ainsi que les différents états par lesquels un écran passe lors de son chargement, de sa modification, de son enregistrement ou parfois de son échec.
En réalité, il s’agissait simplement d’un réaménagement des éléments sans aucun changement quant à celui qui possédait la « maison ».
Cette distinction mérite d’être prise en compte, car la taille seule n’est pas l’ennemie. Un composant peut légitimement être volumineux s’il représente une seule partie cohérente de l’interface utilisateur. Un éditeur dense, un tableau de bord ou une page de rapport peuvent justement contenir beaucoup de balises. Les problèmes commencent lorsque un composant devient le seul endroit où des décisions complètement indépendantes sont prises.
Aucun des schémas présentés ci-dessous n’est intrinsèquement mauvais en React. Chacun a des utilisations légitimes dans un contexte plus restreint ou mieux planifié. Ils ne deviennent un problème que lorsqu’ils apparaissent à plusieurs reprises au sein de composants volumineux, car chaque occurrence augmente le couplage, élargit la surface affectée par les re-renderings et oblige toute modification future à prendre en compte l’ensemble de l’écran en même temps.
Se débarrasser d’eux ne vous laisse pas avec un amas de composants minuscules et dénués de sens. Cela vous permet d’obtenir des structures plus claires en ce qui concerne l’état, la récupération de données, les interactions et le rendu. Le code devient plus facile à modifier, simplement parce que moins de parties d’une fonctionnalité peuvent interférer involontairement les unes avec les autres.
1. Composants qui ont transmis l’ensemble de la fonctionnalité vers le bas
Une habitude fréquemment observée sur les écrans plus grands est l’inclusion de gros objets et de longues listes de fonctions de rappel dans presque tous les composants enfants.
Imaginez une table de résultats qui reçoit l’utilisateur actuel, un objet complet des permissions, les filtres actifs, la ligne sélectionnée, un indicateur de chargement, plusieurs fonctions de mutation, des paramètres pour les modaux et des gestionnaires de notifications — ainsi que quelques valeurs qu’elle ne touche jamais directement. Cette table transmet ensuite une partie de ces éléments directement aux composants de ligne, qui les renvoient à leur tour dans des cellules et des boutons individuels.
En examinant l’arborescence des fichiers, tout semble bien séparé. Cependant, en observant les données réellement échangées entre les composants, on constate une situation différente : chaque composant enfant reste connecté à l’ensemble de la fonctionnalité.
Cela engendre deux problèmes distincts. Premièrement, la compréhension en pâtit : il est difficile de comprendre un composant qui accepte quinze propriétés, car sa fonction réelle est masquée par des détails qui appartiennent en réalité à son composant parent. Deuxièmement, les modifications se propagent de manière imprévisible. Changer le nom d’un champ de permission ou modifier la signature fonctionnelle d’une action signifie devoir éditer plusieurs niveaux de composants qui n’avaient à l’origine aucune responsabilité réelle concernant ce comportement.
La solution consiste à remplacer les propriétés larges, définies par fonctionnalité, par des contrats plus restreints correspondant précisément aux responsabilités de chaque composant enfant. Une table a besoin uniquement de lignes, d’un état de sélection et d’une fonction de rappel comme onRowSelected. Un menu d’actions n’a besoin que des actions spécifiques disponibles pour un enregistrement donné — et non de l’ensemble du modèle de permissions ainsi que de toutes les fonctions de mutation existantes.
Pour y parvenir, il faut généralement créer un petit modèle de vue ou un modèle d’action avant l’affichage. Cette étape supplémentaire de préparation est précieuse, car elle oblige le composant parent à transformer l’état brut de l’application en une interface plus concise et spécifiquement conçue avant de transmettre quoi que ce soit.
L’objectif n’est jamais de réduire le nombre de propriétés pour leur propre compte. Le véritable avantage est que les composants enfants n’ont plus besoin de comprendre le fonctionnement global de la page. Ils doivent uniquement savoir quels données afficher et quelle intention communiquer vers le haut.
Un composant volumineux devient fragile dès que chaque descendant porte en lui une partie du monde interne complet de son parent. Des contrats restreints et spécifiquement définis permettent aux différentes parties de l’interface utilisateur d’évoluer indépendamment, sans avoir à impliquer toute la fonctionnalité dans chaque interaction.
2. Objets et fonctions nouveaux créés à chaque rendu
Chaque composant React génère naturellement de nouvelles valeurs lorsqu’il s’affiche. Vous transmettez un objet d’options à un enfant, vous filtrez un tableau ou vous écrivez un gestionnaire d’événement en ligne de texte. La plupart du temps, rien de tout cela n’a d’importance.
Mais à l’intérieur d’un arbre de composants profond, une référence fraîchement créée peut cacher le fait que la mémoisation est compromise à plusieurs niveaux en dessous du composant qui l’a générée.
Considérez un composant enfant construit avec memo : il s’affiche à nouveau chaque fois que l’objet qu’il reçoit possède une nouvelle identité à chaque passage vers un parent, même si les valeurs réelles des champs à l’intérieur de cet objet n’ont jamais changé. Un effet est relancé parce que son tableau de dépendances contient un objet de configuration nouvellement créé. Une table recalcule ses colonnes parce que les définitions de celles-ci ont été reconstruites après un changement d’état modal complètement indépendant.
Le code peut sembler parfaitement stable tout en cachant ce problème :
<ResultsTable
columns={[
{ key: "name", label: "Name" },
{ key: "status", label: "Status" },
]}
options={{
selectable: true,
compact: false,
}}
/>
D’un point de vue JavaScript, tant le tableau que les objets qu’il contient sont entièrement nouveaux à chaque rendu. Le fait que cela provoque réellement des problèmes dépend entièrement de ce qui les utilise.
Recourir à useMemo et useCallback comme solution universelle n’est pas non plus la bonne approche. En encapsulant tout dans une mémoisation, on ajoute simplement sa propre couche de complexité ainsi que des problèmes liés aux tableaux de dépendances. Un meilleur point de départ consiste à se demander si la valeur a réellement besoin d’être générée au cours du rendu.
La configuration statique peut être entièrement déplacée en dehors du composant. La configuration qui change occasionnellement peut être placée dans un hook dédié. Lorsqu’un objet existe uniquement pour regrouper quelques primitives, il est généralement plus propre de les transmettre directement. Les gestionnaires d’événements n’ont besoin d’être stabilisés avec useCallback que lorsque leur identité a réellement de l’importance — pour les abonnements, les enfants mémorisés ou des recalculs coûteux en aval.
L’objectif n’est jamais la pureté référentielle pour elle-même. Créer de petites valeurs pendant le rendu est normal et généralement inoffensif. La prudence doit être réservée aux cas où l’identité référentielle effectue réellement du travail ailleurs dans l’arborescence.
Dans une composante volumineuse, une seule mise à jour mineure de l’état peut réafficher le composant parent et générer un ensemble complet de valeurs en cours de route. Si chaque enfant considère une référence modifiée comme des données modifiées, une petite mise à jour locale se transforme en invalidation de toute la page.
3. Éliminer la seule requête qui alimentait toute l’interface
Les pages volumineuses commencent souvent par une seule requête conçue pour récupérer tout ce dont l’interface a potentiellement besoin. Cette seule réponse regroupe des métriques de synthèse, des lignes de tableau, des filtres, des enregistrements associés, des données de permission, un historique récent, et même des champs nécessaires uniquement à une boîte modale que l’utilisateur pourrait ne jamais ouvrir.
Cette approche semble efficace à première vue, puisque l’interface ne présente qu’un seul état de chargement et une seule source de données évidente. Mais elle lie également chaque partie de la page au segment le plus lent et le moins fiable de cette réponse.
Si le service d’historique échoue, le tableau principal pourrait ne jamais s’afficher du tout. Une collection associée volumineuse augmente la charge initiale, même pour les utilisateurs qui n’ouvrent jamais le panneau qui en a besoin. De plus, la mise à jour d’une seule section force un rechargement complet, car toute la page partage une seule frontière de données.
Une approche plus efficace consiste à remplacer ces requêtes de taille page par des frontières de données correspondant aux responsabilités visibles réelles de l’écran. Le contenu principal s’affiche en premier. Les panneaux secondaires ne récupèrent leurs propres données qu’une fois devenus pertinents. Les détails coûteux ne sont récupérés que lorsque l’utilisateur ouvre un enregistrement spécifique, plutôt que d’être inclus par défaut dans chaque ligne.
Cela ne signifie pas de lancer une requête réseau pour chaque widget insignifiant. Une séparation excessive de ce type crée ses propres problèmes : requêtes en cascade, appels redondants et états de chargement incohérents. La frontière qui compte réellement est généralement une région disposant de son propre cycle de vie ainsi que de sa propre définition de ce qu’est une « erreur ».
Un widget de résumé et un journal d’audit historique n’ont pas besoin d’échouer ou de réussir en tant qu’unité unique. Une table et un panneau d’édition peu utilisé n’ont pas besoin de partager le même en-tête initial. Une fois ces éléments séparés, la page peut rester fonctionnelle même si l’une des sections optionnelles tombe en panne.
La composante elle-même devient également beaucoup plus facile à comprendre, puisqu’elle n’a plus besoin de modéliser une forme de réponse massive et complexe. Chaque région reçoit le contrat de données dont elle a réellement besoin, et l’invalidation du cache peut cibler uniquement les enregistrements qui ont changé plutôt que toute la page.
Les composantes volumineuses ont tendance à devenir fragiles précisément lorsque leur modèle de données est dicté par des considérations liées au niveau de la page plutôt que par le cycle de vie naturel des fonctionnalités qui s’y trouvent.
4. Élimination des composantes génériques contrôlées par des dizaines de paramètres
Une réaction courante pour éviter les doublons consiste à créer des composantes extrêmement configurables. Un seul panneau peut devenir recherchable, sélectionnable, paginé, editable, exportable et rétractable, tout cela étant contrôlé par un nombre croissant de propriétés booléennes.
Cela semble reutilisable car il couvre apparemment une grande variété de scénarios. En réalité, chaque nouvelle interface signifie simplement l’ajout d’une condition de plus à la pile.
<DataPanel
searchable
selectable
showToolbar
allowExport={canExport}
inlineEdit={mode === "admin"}
compact={isInsideModal}
hidePagination={rows.length < 20}
stickyHeader={!isMobile}
/>
Le véritable problème n’est pas seulement le grand nombre de propriétés. Les flags interagissent entre eux de manières imprévisibles. L’édition en ligne se comporte différemment une fois le mode de sélection activé. Le layout compact nécessite sa propre logique de barre d’outils. Un en-tête collant se brise à l’intérieur d’un conteneur spécifique souffrant de débordement. Le nombre de combinaisons possibles de flags augmente bien plus rapidement que la capacité de quiconque à les tester réellement.
Le composant se transforme silencieusement en une deuxième application cachée à l’intérieur de la première.
Remplacer ce réutilisation basée sur des flags signifie privilégier des éléments plus petits et composables, ainsi que des variantes distinctes explicites là où les différences sont significatives. Une table peut être associée à une barre d’outils, à un contrôle de pagination et à un outil de sélection selon les besoins. Une table éditable peut exister en tant que fonctionnalité indépendante, plutôt que d’être simplement une autre branche booléenne intégrée à un composant de table générique.
Lorsque deux interfaces partagent une structure sous-jacente réelle, cette structure est réutilisée directement. Lorsqu’elles se ressemblent uniquement sur une capture d’écran mais suivent des workflows différents, forcer leur traitement à travers une abstraction commune perd tout sens.
Cela réintroduit une certaine duplication qui était auparavant cachée, ce qui peut sembler un pas en arrière au début. En pratique, cette duplication est généralement bien moins coûteuse que l’architecture en branches qu’elle remplace. Deux composants petits et distincts peuvent être modifiés indépendamment, sans que chaque modification doive être vérifiée par rapport à une douzaine de combinaisons de paramètres non liées.
La réutilisation est avantageuse lorsqu’elle permet de protéger un contrat unique et stable. Elle devient risquée dès lors qu’elle consiste à étendre un composant afin de représenter secrètement plusieurs produits différents en même temps.
5. Supprimer l’état modal de la page qui a ouvert le modal
Les composants volumineux finissent souvent par agir comme des gestionnaires de modaux. Ils suivent si chaque boîte de dialogue est ouverte, quel enregistrement elle modifie actuellement, à quelle étape du flux elle se trouve, si elle est en train d’enregistrer, et quel erreur elle a affichée pour la dernière fois.
Souvent, la page elle-même ne contient qu’une seule ligne qui déclenche l’ouverture du modal, mais elle est néanmoins responsable de tout le cycle de vie de ce dialogue, quel que soit son contenu.
Cela crée un état qui reste actif même lorsque le dialogue est complètement invisible. Le fermer correctement signifie effacer les enregistrements sélectionnés, les valeurs des champs de brouillon, les erreurs de validation et les demandes en attente, dans un ordre précis. Ouvrir un autre dialogue par la suite risque d’utiliser accidentellement des valeurs résiduelles provenant du dialogue précédent.
Considérer tout dialogue non trivial comme une fonctionnalité à part entière, plutôt que comme du JSX conditionnel ajouté à la page, change cette dynamique. La tâche de la page devient alors de déterminer quel enregistrement l’utilisateur souhaite modifier. Le dialogue lui-même gère les données de brouillon, la logique de validation, les étapes internes ainsi que le cycle de soumission.
Dans certains cas, il suffit de ne charger le dialogue que lorsqu’il est réellement ouvert pour que son état temporaire soit automatiquement réinitialisé. Dans d’autres cas, l’état doit persister après la fermeture, ce qui le fait passer dans un espace dédié plutôt que de rester lié à l’état du tableau et des filtres de la page.
Cette séparation rend également le comportement asynchrone nettement plus sûr. Un dialogue de modification peut annuler ou rejeter les demandes obsolètes dès sa fermeture. Une action de sauvegarde peut déterminer par elle-même si le dialogue reste ouvert en cas d’échec. La page n’a plus besoin de coordonner des mécanismes internes de formulaire qu’elle ne comprend pas vraiment.
La page conserve toujours le contrôle de la connexion entre le tableau et le dialogue — elle sait quel enregistrement est sélectionné et ce qui doit se produire une fois l’édition réussie. Mais elle ne contrôle plus chaque champ et transition simplement parce que le dialogue a été lancé depuis cette page.
Un élément modal peut sembler visuellement superposé à l’écran, mais cette superposition visuelle ne signifie pas pour autant que son cycle de vie complet doit être géré au sein du composant de l’écran.
6. Extraire les vérifications de permissions des JSX dispersés
La logique d’autorisation a tendance à s’infiltrer progressivement dans les composants. Un bouton est caché pour les utilisateurs ordinaires, un élément de menu est désactivé une fois qu’un enregistrement est archivé, et une section n’est affichée que pour les administrateurs.
Ces conditions se multiplient car JSX rend extrêmement facile l’ajout d’une nouvelle vérification :
{user.role === "admin" && record.status !== "archived" && (
<DeleteButton />
)}
Lorsqu’un composant devient trop volumineux, il peut contenir plusieurs versions légèrement différentes de ce qui devrait être la même règle. Une condition détermine si quelque chose est visible, une autre si son gestionnaire s’exécute, et une troisième si une entrée de menu est grisée. Avec le temps, ces copies s’éloignent les unes des autres et cessent de coïncider.
Cet éloignement est principalement un bug de correction, mais il nuit également à la lisibilité. Le markup de mise en page se mélange avec les politiques métier, et quiconque lit le composant doit évaluer mentalement les expressions de permission dispersées dans toute la structure pour comprendre ce que fait la page.
Au lieu de disperser des vérifications de politique brutes dans le JSX, vous pouvez calculer à l’avance un ensemble explicite de capacités :
const capabilities = getRecordCapabilities({
user,
record,
organization,
});
À partir de là, le composant peut simplement vérifier si l’utilisateur actuel a la permission d’éditer, d’archiver, d’exporter ou de supprimer le record en question. Les noms indiquent des décisions concrètes liées aux produits, plutôt que de révéler directement des champs de base de données ou des chaînes de rôles.
Pour être clair, cela ne déplace pas l’application des règles de sécurité vers le client. Le backend reste la véritable frontière d’autorisation — rien ne change à ce sujet. L’objet de capacités présent dans l’interface utilisateur existe uniquement pour fournir une source unique et cohérente d’informations, afin d’éviter de devoir redéfinir la même règle dans cinq branches visuelles différentes qui pourraient silencieusement devenir désynchronisées.
Cela rend également les règles elles-mêmes bien plus faciles à tester. Vous pouvez appliquer la logique de permissions à différents rôles, configurations de propriété, états et paramètres d’organisation sans avoir à afficher l’ensemble de l’arbre des composants. Lorsque la politique sous-jacente change, le composant adjacent n’a généralement pas besoin de changer du tout.
Les grands arbres JSX deviennent fragiles lorsqu’ils servent également à créer des règles métier en temps réel. L’affichage devrait principalement consister à utiliser des décisions déjà prises, et non à les reconstruire depuis zéro dans chaque branche conditionnelle.
7. Suppression du chargement global des pages et des indicateurs d’erreur
La plupart des composants volumineux commencent de manière simple avec une seule valeur isLoading et un seul champ error. Cela est acceptable tant que la page ne fait qu’une seule chose. Mais à mesure que les fonctionnalités s’élargissent, ces deux indicateurs finissent par devoir décrire plusieurs activités non liées en même temps.
Une page peut récupérer ses données initiales, mettre à jour un tableau en arrière-plan, enregistrer un formulaire, supprimer une ligne ou exporter un rapport — parfois même au cours de la même session. Un seul booléen de chargement partagé ne permet pas de savoir quelle opération est réellement en cours. Une requête se termine et met le flag à faux, tandis qu’une autre requête complètement différente est encore en exécution. Une erreur générée par une modification écrase l’erreur destinée à décrire le chargement initial de la page. Toute l’interface se bloque simplement parce qu’une mise à jour en arrière-plan non liée est en train de s’exécuter.
Une approche plus efficace consiste à remplacer ces indicateurs d’état applicables à toute la page par des indicateurs propres à l’opération spécifique qu’ils décrivent.
Cela signifie que la requête initiale peut être en cours de chargement tandis que le tableau existant reste entièrement visible et interactif. Une seule ligne peut être en train d’être supprimée sans que les autres lignes de la page ne soient désactivées. L’éditeur peut être en train d’envoyer des données tandis que les filtres de la page restent utilisables. Une exportation peut afficher son propre indicateur de progression sans pour autant indiquer que tout l’écran est devenu inutilisable.
Le résultat est des valeurs d’état plus spécifiques, mais chacune d’elles a un sens clair et sans équivoque. Rien dans le code n’a besoin de deviner à quoi fait réellement référence un indicateur générique isLoading à un moment donné.
La même logique s’applique aux échecs : il n’est pas nécessaire que tous se retrouvent sur une seule page d’erreur de niveau supérieur. Un panneau optionnel qui a échoué peut afficher sa propre option de tentative. Une erreur de mutation peut rester confinée au flux de travail qui l’a déclenchée. La page principale ne devient indisponible que lorsque les données nécessaires à son affichage n’ont pas pu être chargées.
Cela permet d’obtenir une interface plus résiliente ainsi qu’un modèle de composants plus facile à comprendre. Le statut n’est plus considéré comme global simplement parce que l’opération se trouve à l’intérieur d’un grand composant de page.
Un grand composant donne souvent l’impression d’être instable, car plusieurs flux de travail distincts sont contraints de partager un seul signal. Fournir à chaque flux de travail son propre statut permet à l’interface d’indiquer avec précision ce qui fonctionne et ce qui ne fonctionne pas à un moment donné.
L’objectif n’a jamais été uniquement des fichiers plus petits
Lorsque ces schémas sont supprimés, de nombreux composants deviennent effectivement plus courts — mais c’est un effet secondaire, et non l’objectif principal.
Le véritable avantage réside dans le fait que moins de décisions non liées sont prises au sein d’une même zone de rendu. Les composants enfants reçoivent des contrats définis en fonction de leurs responsabilités respectives, plutôt que de l’état complet de la fonctionnalité. L’identité des références empêche désormais l’invalidation silencieuse de gros sous-arbres à chaque rendu. Les données sont récupérées en fonction de ce qui est réellement visible à l’écran, et non via une seule requête couvrant toute la page. Les composants réutilisables cessent d’accumuler un nouveau flag pour chaque variation possible que quiconque pourrait avoir besoin.
Les modaux gèrent eux-mêmes leur état. Les règles de permission se transforment en capacités nommées, au lieu de conditions insérées directement dans le code. Les états de chargement et d’erreur appartiennent aux opérations spécifiques qui les génèrent.
Certaines interfaces restent grandes, simplement parce que l’interface elle-même est réellement vaste — et ce n’est pas un problème. Le code devient plus facile à manipuler non pas parce qu’il est devenu plus petit, mais parce que sa taille reflète une structure réelle et visible plutôt qu’une logique de coordination cachée.
À ce stade, la question pertinente concernant un composant React n’est pas le nombre de lignes qu’il contient, mais plutôt le nombre de raisons indépendantes qui pourraient le faire changer. Si modifier l’éditeur signifie également devoir comprendre la requête utilisée pour afficher les données, l’état d’exportation, le modèle de permissions ainsi que chaque modal présent sur la page, le problème ne réside ni dans le formatage ni dans la longueur du fichier. Les limites de responsabilité sont simplement définies au mauvais endroit.
Les composants React volumineux restent gérables dès qu’ils cessent d’essayer de fonctionner seuls comme s’ils représentaient toute l’application.
L’objectif n’a jamais été de continuer à diviser un fichier jusqu’à ce que chaque fonction devienne minuscule.
L’objectif est de s’assurer que chaque partie d’une fonctionnalité ne connaisse que les décisions pour lesquelles elle est réellement responsable.
Lectures complémentaires
- Dix erreurs cachées des composants React qui ralentissent les applications modernes — Découvrez dix erreurs courantes dans les composants React, allant des lacunes dans l’HTML sémantique à l’absence de mémoisation, ainsi que les solutions nécessaires pour maintenir des applications rapides, accessibles et sans bugs en 2026.
- Résoudre le problème d’overload des props de React avec la composition et les slots — Comprenez pourquoi les props de React à forte charge en configuration génèrent des dettes de maintenance, et comment l’inversion de contrôle, la composition et les slots permettent de créer des composants véritablement réutilisables.