Splitting components into smart (container) and dumb (presentational, “UI”) is the most useful architecture rule for components. It answers the question: who is allowed to know where the data comes from? The answer: as few components as possible.
| Smart / container | Dumb / presentational | |
|---|---|---|
| Role | Orchestration: gets data, handles actions | Display: shows data, emits intentions |
| Injects | stores, API services, Router | nothing business-related (only UI: ElementRef, DestroyRef…) |
| Communication | store / services | only input(), output(), model() |
| State | business state (or delegates it to a store) | only local UI state (hover, expanded) |
| Reuse | rare — tied to one use case | frequent — wherever you have the same data |
| Testing | with mocks / a fake store | trivial: you give inputs, you check outputs |
| Where it lives | features/x/pages | features/x/ui or shared/ui |
A complete example: a product catalogue
A smart page (CatalogPage) with a feature store (CatalogStore) and three dumb components: the filters, the product card and the cart summary. The data comes from the simulated backend through httpResource.
// SMART (container): it knows WHERE the data comes from and WHAT happens on an action.
@Component({
selector: 'app-catalog-page',
imports: [ProductFilters, ProductCard, CartSummary],
providers: [CatalogStore], // the store lives as long as the 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'];
}Where the files live
features/catalog/
├── catalog.routes.ts
├── pages/
│ └── catalog-page.ts ← SMART (routed)
├── data-access/
│ ├── catalog-store.ts ← state + data
│ └── catalog-api.ts
├── ui/ ← DUMB, specific to the feature
│ ├── product-card.ts
│ ├── product-filters.ts
│ └── cart-summary.ts
└── models/
└── product.ts ← Product + toProduct(dto)
shared/ui/ ← DUMB, generic (no domain)
├── button.ts
├── rating.ts
└── data-table.tsAnti-pattern: the “dumb” component that is really smart
// ✗ A “dumb” component that is really smart in disguise
@Component({ selector: 'app-product-card', /* ... */ })
export class ProductCard {
private readonly cart = inject(CartStore); // ← dependency on global state
private readonly http = inject(HttpClient); // ← makes requests
private readonly router = inject(Router); // ← decides the 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']);
}
}
// Consequences: you can’t reuse it on another page (e.g. in “recommendations” or in admin),
// you can’t test it without mocks for HttpClient, Router and CartStore,
// and a list of 50 cards makes 50 requests.What a dumb component is allowed to do
// ✓ Local UI state in a dumb component — perfectly OK
export class Accordion {
readonly items = input.required<Item[]>();
protected readonly expanded = signal<number | null>(null); // only about the display
}
// ✓ “UI” injections in a dumb component — OK
inject(ElementRef); inject(DestroyRef); inject(LOCALE_ID); inject(Dialog /* CDK */);Practical rules
- One routed page = one smart container. It connects the URL, the store and the UI.
- Dumb components don’t inject stores or data services. If you feel the need, lift the logic one level up.
- Outputs express intentions, not implementations:
(addToCart), not(callCartServiceAndNavigate). - Not everything has to be dumb. A complex widget inside a page (e.g. “comments”) can be a mini smart container with its own store — better than passing 15 inputs through 4 levels.
- Prop drilling over 2–3 levels is the signal that you need an intermediate container or a feature store.
The state a smart component holds usually lives in a store: State management with signals. How the two talk to each other is covered in input() and output().
When YES, when NO
- It displays received data (card, table row, avatar, badge).
- It is a control (filter, rating, toggle) — the state comes through
model(). - You want to reuse it on another page or in
shared/ui. - You want to test it easily or show it in Storybook.
- It is a routed page.
- It is a self-contained widget with its own data (chat, notifications) used “drop-in” on several pages.
- Making it dumb would take 10+ inputs/outputs passed through several levels.