B / Blog React

Comment je structure une grande application React

Un guide d’architecture assumé pour organiser les fonctionnalités, la logique métier, les clients API, les composants partagés, les API de modules, les dépendances et les tests d’une application React en production.

Axe
Architecture React
Publié
Temps de lecture estimé
13 min de lecture
  • Architecture React
  • Organisation par fonctionnalités
  • Frontières de modules
  • Tests frontend

Une grande application React ne devient pas difficile à maintenir parce qu’elle contient trop de fichiers. Elle le devient lorsqu’une modification n’a pas de responsable évident, que les dépendances peuvent pointer dans n’importe quelle direction et que chaque abstraction utile finit peu à peu par devenir globale.

Ma structure de prédilection s’organise par fonctionnalités, mais les dossiers ne constituent pas l’architecture. L’architecture réside dans les décisions qui les sous-tendent : ce qu’un module possède, ce qu’il peut importer, ce qu’il expose et la manière dont le code passe du local au partagé à mesure que le produit évolue.

J’optimise l’organisation autour d’une question concrète : lorsqu’un développeur reçoit une modification à réaliser, peut-il lui trouver sa place naturelle, comprendre ses dépendances et l’effectuer sans devoir apprendre toute l’application ?

Ce guide présente la structure que j’utilise par défaut pour les applications React en production. Elle repose sur des choix assumés, mais ce n’est pas un modèle à reproduire mécaniquement. Les frontières comptent davantage que le nom exact des dossiers.

B-01

Pourquoi les dossiers génériques deviennent des fourre-tout

De nombreuses applications React commencent avec une structure de ce type :

src/
|-- components/
|-- hooks/
|-- services/
|-- types/
|-- utils/
`-- pages/

Elle paraît organisée parce que chaque fichier appartient à une catégorie technique. Le problème est que ces catégories ne disent rien sur la responsabilité fonctionnelle.

Un tableau d’historique des commandes, une boîte de dialogue de paiement et la navigation principale sont tous des composants. Une fonction de formatage monétaire, une vérification d’autorisation et un calcul de paiement peuvent tous être qualifiés d’utilitaires. Un hook de récupération de données et un hook de raccourci clavier restent tous deux des hooks, alors qu’ils appartiennent à des parties complètement différentes du produit.

À mesure que l’application grandit, ces dossiers présentent des modes d’échec prévisibles :

  • Les développeurs doivent chercher dans plusieurs répertoires globaux pour comprendre une seule fonctionnalité.
  • Des noms similaires s’accumulent parce que le dossier ne fournit plus de contexte métier.
  • Des détails d’implémentation privés deviennent des imports pratiques pour des fonctionnalités sans rapport.
  • Des modifications qui devraient rester locales exigent d’intervenir dans `components`, `hooks`, `services`, `types` et `utils`.
  • Personne ne sait plus ce qui est véritablement partagé et ce qui a simplement atterri dans un emplacement partagé.
  • Des dépendances circulaires apparaissent faute de direction de dépendance explicite.

Le problème ne vient pas des noms `components`, `hooks` ou `utils`. J’utilise toujours ces dossiers à l’intérieur d’un module bien délimité. Le problème consiste à faire du type technique le premier principe d’organisation.

À l’échelle de l’application, j’organise le code selon sa responsabilité et sa raison de changer.

B-02

La structure à partir de laquelle je travaille

Pour un produit conséquent, ma structure par défaut ressemble à ceci :

src/
|-- app/
|   |-- routes/
|   |-- providers/
|   `-- composition/
|-- domains/
|   |-- orders/
|   |   |-- model/
|   |   |-- policies/
|   |   |-- tests/
|   |   `-- index.ts
|   `-- accounts/
|-- features/
|   |-- cancel-order/
|   |   |-- api/
|   |   |-- components/
|   |   |-- hooks/
|   |   |-- model/
|   |   |-- tests/
|   |   `-- index.ts
|   `-- edit-billing-address/
`-- shared/
    |-- api/
    |-- ui/
    |-- lib/
    |-- config/
    `-- testing/

e2e/

La couche applicative assemble les routes, les providers et les workflows de premier niveau. Les modules de domaine contiennent les concepts et les règles métier stables. Les modules fonctionnels implémentent les capacités offertes aux utilisateurs. Les modules partagés fournissent l’infrastructure et les primitives d’interface indépendantes du produit. Les tests de bout en bout se trouvent hors de `src`, car ils exercent le système assemblé plutôt qu’un seul module source.

Je ne crée pas tous ces répertoires dès le premier jour. Un dossier doit exister parce que du vrai code porte une responsabilité qu’il faut nommer. Une architecture vide n’est que du cérémonial.

B-03

Les modules fonctionnels portent les capacités utilisateur

Une fonctionnalité est un comportement qu’un utilisateur ou un autre système peut déclencher : annuler une commande, inviter un membre dans une équipe, modifier un moyen de paiement ou exporter un rapport. Je préfère les noms qui décrivent une capacité aux périmètres vagues tels que `dashboard` ou `common`.

Un module fonctionnel peut prendre en charge :

  • Les composants React utilisés pour exécuter cette capacité.
  • Les hooks et l’état d’interaction propres à la fonctionnalité.
  • La validation et la transformation des données de formulaire.
  • Les opérations API utilisées uniquement par la fonctionnalité.
  • La coordination entre les règles métier et l’infrastructure.
  • Les tests qui protègent le comportement de la fonctionnalité.

Les fichiers qui changent ensemble restent ainsi proches les uns des autres. Un développeur qui travaille sur l’annulation d’une commande ne devrait pas devoir parcourir six dossiers globaux pour retracer une seule interaction.

Une organisation par fonctionnalités ne signifie pas qu’il faut placer tout ce qui concerne les commandes dans un énorme dossier `orders`. Un domaine est un concept métier. Une fonctionnalité est une opération qui implique ce concept. Cette distinction empêche un grand module de domaine de devenir le prochain fourre-tout.

La couche de routage ou la couche applicative assemble les fonctionnalités. Une fonctionnalité ne doit pas fouiller dans les fichiers internes d’une autre pour lui emprunter un hook ou un composant. Si deux fonctionnalités ont besoin de la même règle métier, celle-ci appartient probablement à un module de domaine. Si elles ont besoin de la même primitive technique, celle-ci appartient probablement à `shared`.

B-04

Maintenir la logique métier indépendante de React

La logique métier décrit ce que le produit autorise et la manière dont ses valeurs métier se comportent. Elle ne doit dépendre ni du rendu React, ni des API du navigateur, ni d’un cache de requêtes, ni de la forme d’une réponse HTTP.

export type Order = {
  id: string;
  status: "draft" | "confirmed" | "shipped" | "cancelled";
  refundableUntil: Date | null;
};

export function canCancelOrder(order: Order, now: Date): boolean {
  if (order.status === "shipped" || order.status === "cancelled") {
    return false;
  }

  return order.refundableUntil === null || now <= order.refundableUntil;
}

Cette règle peut être utilisée par un bouton, une garde de route, un traitement en arrière-plan ou un test sans monter React ni simuler `fetch`. La fonctionnalité décide quand l’appeler et comment présenter le résultat. Le domaine décide de ce qui est valide.

Toutes les conditions ne justifient pas un dossier `domains`. Une règle purement visuelle utilisée par un seul composant peut rester dans cette fonctionnalité. Je fais remonter une logique dans un module de domaine lorsqu’elle exprime un langage métier partagé, porte des invariants significatifs ou doit rester cohérente entre plusieurs workflows.

B-05

Placer les clients API à la frontière d’un module

Je sépare les mécanismes de transport des opérations du produit.

La couche API partagée peut prendre en charge l’URL de base, les en-têtes d’authentification, l’annulation des requêtes, la normalisation des erreurs, les en-têtes de traçage et l’analyse du JSON. Elle ne doit pas savoir comment annuler une commande ou inviter un membre.

L’adaptateur de la fonctionnalité ou du domaine concerné possède les chemins des endpoints, les DTO de requête et de réponse, ainsi que la conversion des données de transport en types applicatifs.

import { httpClient } from "@/shared/api";

type CancelOrderResponseDto = {
  order_id: string;
  status: "cancelled";
  cancelled_at: string;
};

export async function cancelOrder(orderId: string) {
  const response = await httpClient.post<CancelOrderResponseDto>(
    `/orders/${orderId}/cancellation`,
  );

  return {
    orderId: response.order_id,
    status: response.status,
    cancelledAt: new Date(response.cancelled_at),
  };
}

Cette frontière empêche les formats d’échange de se répandre dans les composants et les règles métier. Si le backend renomme un champ ou renvoie une nouvelle représentation, la modification est absorbée à l’endroit où le contrat externe entre dans le module.

J’évite un fichier global `api.ts` ou `services.ts` qui contiendrait tous les endpoints. De tels fichiers centralisent des sources de changement sans rapport entre elles. Ils semblent pratiques jusqu’à ce que chaque équipe doive modifier le même module et que chaque consommateur puisse importer n’importe quelle opération.

B-06

Donner à chaque module une API publique

Chaque domaine et chaque fonctionnalité expose une petite surface publique depuis son fichier `index.ts` racine.

// features/cancel-order/index.ts
export { CancelOrderDialog } from "./components/cancel-order-dialog";
export { useCancelOrder } from "./hooks/use-cancel-order";
export type { CancelOrderResult } from "./model/cancel-order-result";

Les consommateurs importent depuis la frontière du module :

import {
  CancelOrderDialog,
  type CancelOrderResult,
} from "@/features/cancel-order";

Ils n’importent pas depuis des chemins privés :

import { cancelOrder } from "@/features/cancel-order/api/cancel-order";

L’API publique est une décision d’architecture. Elle indique aux autres modules quels contrats sont stables et laisse au responsable du module la liberté de renommer des fichiers, de remplacer une bibliothèque de gestion d’état ou de réorganiser les détails internes sans modifier le reste de l’application.

Je n’ajoute pas de fichiers d’agrégation dans chaque répertoire. Ils masquent l’origine des éléments et peuvent créer des cycles. J’utilise un seul point d’entrée public à la frontière d’un module qui a un véritable sens architectural.

B-07

Imposer un seul sens de dépendance

Le modèle de dépendances reste volontairement simple :

app  ----> features ----> domains
 |           |              |
 +-----------+--------------> shared
  • La couche `app` peut assembler les fonctionnalités, les domaines et les primitives partagées.
  • Les fonctionnalités peuvent dépendre des modules de domaine et de l’infrastructure partagée.
  • Les domaines peuvent dépendre de petites primitives partagées et stables, mais pas des fonctionnalités ni de `app`.
  • `shared` ne doit dépendre ni des fonctionnalités du produit ni des domaines.
  • Une fonctionnalité ne doit pas importer l’implémentation privée d’une autre fonctionnalité.

Lorsque deux fonctionnalités doivent se coordonner, je les assemble dans la couche applicative ou j’extrais le comportement métier stable qu’elles partagent. Je ne résous pas le problème par un import relatif profond.

Ces règles doivent pouvoir être appliquées automatiquement. Les alias de chemins améliorent la lisibilité, mais ils ne créent pas de frontières. Dans une grande équipe, j’ajoute des règles de lint limitant les imports, un outil de contrôle des frontières de modules ou des exports de packages, afin qu’un import invalide échoue avant la revue de code. Dans un monorepo, les mêmes règles peuvent s’exprimer sous forme de dépendances entre packages.

Il existe des exceptions légitimes. Une règle de frontière doit rendre visible un couplage inhabituel, pas obliger les développeurs à construire des contournements élaborés. Lorsque j’autorise une exception, je consigne pourquoi la dépendance existe, qui en est responsable et ce qui permettrait de la supprimer plus tard.

B-08

Le passage dans `shared` se mérite, ce n’est pas un point de départ

Le dossier `shared` est la plus petite partie de cette structure, pas la plus grande.

L’interface partagée contient des primitives stables de bas niveau telles que `Button`, `Dialog`, `Field`, `Stack` et les tokens de design sémantiques. L’API partagée contient l’infrastructure de transport. La bibliothèque partagée contient des fonctions réellement indépendantes du produit, avec des noms et des contrats clairs. La configuration partagée contient les frontières de configuration qui s’appliquent à toute l’application.

Je ne déplace pas du code dans `shared` simplement parce qu’il est utilisé deux fois. Le nombre de réutilisations est un indice faible. Deux fonctionnalités peuvent se ressembler aujourd’hui et diverger le mois prochain.

Je promeus du code dans `shared` lorsque ces trois conditions sont réunies :

  • Les consommateurs ont besoin du même comportement, pas seulement d’un balisage similaire.
  • L’abstraction possède un nom et un contrat stables.
  • Un seul responsable peut la faire évoluer sans devoir comprendre des workflows propres à chaque fonctionnalité.

Un `OrderSummary` n’appartient pas automatiquement à l’interface partagée. Il parle le langage du domaine des commandes et peut donc appartenir à l’interface publique de ce domaine. Un bouton est différent : son comportement et son contrat d’accessibilité s’appliquent à tout le produit.

Cette règle empêche `shared` de devenir une étiquette valorisante pour du code dont personne ne connaît clairement le responsable.

B-09

Garder les tests auprès de la frontière qu’ils protègent

J’organise les tests selon leur responsabilité, pas selon le vocabulaire d’une bibliothèque de test.

src/
|-- domains/orders/tests/
|   `-- can-cancel-order.test.ts
|-- features/cancel-order/tests/
|   |-- cancel-order-contract.test.ts
|   `-- cancel-order-dialog.test.tsx
`-- shared/testing/
    |-- render-application.tsx
    |-- factories/
    `-- server/

e2e/
`-- cancel-order.spec.ts

Les tests de logique métier pure restent avec le domaine. Les tests de comportement et de contrat d’une fonctionnalité restent avec celle-ci. Le dossier de test partagé contient l’infrastructure utilisée par de nombreux modules : fonctions d’enveloppe de rendu, fabriques de données, constructeurs déterministes et gestionnaires réseau. Il ne doit pas devenir un deuxième dossier `utils` rempli de données de test propres à certains besoins métier.

Les tests de bout en bout se trouvent à la racine du dépôt parce qu’ils exercent des parcours utilisateur déployés qui traversent les frontières des modules. Leur emplacement reflète la portée du test, pas une préférence pour un outil d’exécution particulier.

Une arborescence de premier niveau divisée en tests unitaires, d’intégration et de composants recrée généralement le même problème de navigation que `components`, `hooks` et `utils`. Ces étiquettes décrivent la manière dont un test s’exécute. Elles ne disent pas qui est responsable du comportement lorsqu’il échoue.

B-10

Comment je décide où placer du nouveau code

Lorsque j’ajoute un fichier, je parcours ces questions dans l’ordre :

  • Quelle capacité utilisateur ou quel concept métier porte ce comportement ?
  • Le code coordonne-t-il un workflow, applique-t-il une règle métier ou fournit-il de l’infrastructure ?
  • Quel est le module le plus restreint qui peut le prendre en charge sans dupliquer le sens ?
  • Quels modules ont besoin de l’importer aujourd’hui ?
  • De quoi ces consommateurs doivent-ils être autorisés à dépendre ?
  • Si l’implémentation change, jusqu’où la modification doit-elle se propager ?

La dernière question est particulièrement utile. Si une modification de la réponse d’une API oblige à intervenir dans des composants, des hooks et des pages, la frontière de transport fuit. Si une modification de l’état interne d’une fonctionnalité en casse une autre, son API publique est trop large. Si une modification d’une règle métier impose de fouiller les composants d’interface, la logique métier n’a pas de responsable clairement identifié.

L’architecture se révèle dans le coût et la portée des modifications.

B-11

Évoluer vers cette structure une tranche après l’autre

Je n’interromprais pas la livraison pour réorganiser une application existante selon cette arborescence. Les grandes restructurations génèrent beaucoup de remaniements sans démontrer que les nouvelles frontières sont meilleures.

Je choisis plutôt une tranche verticale significative. Je place ses composants d’interface, sa logique d’interaction, son adaptateur API et ses règles métier derrière l’API publique d’une fonctionnalité. Je corrige le sens des dépendances autour de cette tranche, puis j’utilise la fonctionnalité suivante pour vérifier que le modèle tient toujours.

La structure justifie sa place lorsque les modifications courantes deviennent plus locales, que le code privé le reste et que les développeurs peuvent expliquer pourquoi une dépendance est autorisée. Si une frontière résiste constamment au produit, je la change. La cohérence est précieuse, mais protéger la mauvaise abstraction ne l’est pas.

B-12

La règle finale

Je ne juge pas une architecture React à la symétrie de son arborescence. Je la juge à sa capacité à établir des responsabilités claires et à maîtriser le changement.

Une structure solide indique à l’équipe où placer un comportement, empêche les couplages accidentels, maintient les contrats externes aux frontières et n’expose que les API destinées aux autres modules. Les dossiers de fonctionnalités assurent la proximité. Les modules de domaine protègent les règles métier. Le code partagé reste réellement partagé. Les tests suivent le comportement qu’ils protègent. Les règles de dépendance rendent l’architecture applicable.

Voilà l’objectif : pas une arborescence parfaite, mais une application capable de grandir sans imposer à chaque développeur de garder tout le système en tête.

B-13

Où cette approche s’applique

Définir la responsabilité des fonctionnalités, les frontières des domaines, les adaptateurs API et le sens des dépendances fait partie de la construction d’une fondation React qu’une équipe de livraison peut faire évoluer avec confiance et finir par s’approprier pleinement.

Découvrir « Construire sur des bases solides »