Séparer les composants en smart (container) et dumb (de présentation, « UI ») est la règle d’architecture la plus utile pour les composants. Elle répond à la question : qui a le droit de savoir d’où viennent les données ? La réponse : le moins de composants possible.
| Smart / container | Dumb / de présentation | |
|---|---|---|
| Rôle | Orchestration : récupère les données, gère les actions | Affichage : montre les données, émet des intentions |
| Injecte | stores, services d’API, Router | rien de métier (uniquement de l’UI : ElementRef, DestroyRef…) |
| Communication | store / services | uniquement input(), output(), model() |
| État | état métier (ou le délègue à un store) | uniquement l’état d’UI local (survol, déplié) |
| Réutilisation | rare — liée à un seul cas d’usage | fréquente — partout où vous avez les mêmes données |
| Tests | avec des mocks / un faux store | trivial : vous fournissez des inputs, vous vérifiez les outputs |
| Où il vit | features/x/pages | features/x/ui ou shared/ui |
Un exemple complet : un catalogue de produits
Une page smart (CatalogPage) avec un store de feature (CatalogStore) et trois composants dumb : les filtres, la carte produit et le résumé du panier. Les données viennent du backend simulé via httpResource.
// SMART (container) : il sait D’OÙ viennent les données et CE QUI se passe lors d’une action.
@Component({
selector: 'app-catalog-page',
imports: [ProductFilters, ProductCard, CartSummary],
providers: [CatalogStore], // le store vit aussi longtemps que la page
template: `
<app-product-filters [categories]="categories" [(query)]="store.query" [(category)]="store.category" />
@for (p of store.products.value(); track p.id) {
<app-product-card [product]="p" (addToCart)="store.add($event)" />
}
<app-cart-summary
[lines]="store.cart()" [count]="store.cartCount()" [total]="store.cartTotal()"
(remove)="store.remove($event)" />
`,
})
export class CatalogPage {
protected readonly store = inject(CatalogStore);
protected readonly categories: ProductCategory[] = ['laptop', 'phone', 'audio', 'accessory'];
}Où vivent les fichiers
features/catalog/
├── catalog.routes.ts
├── pages/
│ └── catalog-page.ts ← SMART (routée)
├── data-access/
│ ├── catalog-store.ts ← état + données
│ └── catalog-api.ts
├── ui/ ← DUMB, propre à la feature
│ ├── product-card.ts
│ ├── product-filters.ts
│ └── cart-summary.ts
└── models/
└── product.ts ← Product + toProduct(dto)
shared/ui/ ← DUMB, générique (sans domaine)
├── button.ts
├── rating.ts
└── data-table.tsAnti-pattern : le composant « dumb » qui est en réalité smart
// ✗ Un composant « dumb » qui est en réalité smart déguisé
@Component({ selector: 'app-product-card', /* ... */ })
export class ProductCard {
private readonly cart = inject(CartStore); // ← dépendance à l’état global
private readonly http = inject(HttpClient); // ← fait des requêtes
private readonly router = inject(Router); // ← décide de la navigation
readonly productId = input.required<number>();
readonly product = httpResource<Product>(() => `/api/products/${this.productId()}`);
add() {
this.cart.add(this.product.value()!);
this.router.navigate(['/cart']);
}
}
// Conséquences : vous ne pouvez pas le réutiliser sur une autre page (par ex. dans les « recommandations » ou dans l’admin),
// vous ne pouvez pas le tester sans mocks pour HttpClient, Router et CartStore,
// et une liste de 50 cartes fait 50 requêtes.Ce qu’un composant dumb a le droit de faire
// ✓ État d’UI local dans un composant dumb — parfaitement OK
export class Accordion {
readonly items = input.required<Item[]>();
protected readonly expanded = signal<number | null>(null); // concerne uniquement l’affichage
}
// ✓ Injections « UI » dans un composant dumb — OK
inject(ElementRef); inject(DestroyRef); inject(LOCALE_ID); inject(Dialog /* CDK */);Règles pratiques
- Une page routée = un container smart. Il relie l’URL, le store et l’UI.
- Les composants dumb n’injectent ni stores ni services de données. Si vous en ressentez le besoin, remontez la logique d’un niveau.
- Les outputs expriment des intentions, pas des implémentations :
(addToCart), pas(callCartServiceAndNavigate). - Tout n’a pas à être dumb. Un widget complexe dans une page (par ex. les « commentaires ») peut être un mini container smart avec son propre store — mieux que de passer 15 inputs à travers 4 niveaux.
- Le prop drilling sur 2 à 3 niveaux est le signe qu’il vous faut un container intermédiaire ou un store de feature.
L’état d’un composant smart vit généralement dans un store : State Management avec les Signals. Comment les deux communiquent : input() et output().
Quand OUI, quand NON
- Il affiche des données reçues (carte, ligne de tableau, avatar, badge).
- C’est un contrôle (filtre, notation, bascule) — l’état passe par
model(). - Vous voulez le réutiliser sur une autre page ou dans
shared/ui. - Vous voulez le tester facilement ou l’afficher dans Storybook.
- C’est une page routée.
- C’est un widget autonome avec ses propres données (chat, notifications) utilisé « tel quel » sur plusieurs pages.
- Le rendre dumb demanderait plus de 10 inputs/outputs transmis à travers plusieurs niveaux.