Smart & Dumb Components
The smart/dumb pattern (also called container/presentational) splits a feature into two kinds of components: smart components that know how to fetch, orchestrate, and mutate data, and dumb components that only render the inputs they receive and emit events when the user does something. By keeping data concerns out of your view components you get pieces that are trivially reusable, fast to render, and easy to test. This separation is one of the highest-leverage structural decisions you can make in an Angular app.
What each kind of component does
A smart (container) component sits near a route or a feature boundary. It injects services, reads from stores or signals, calls APIs, handles navigation, and decides what data exists. It usually has very little template markup of its own — mostly it wires data into dumb components.
A dumb (presentational) component has no idea where its data comes from. It receives everything through inputs and reports user intent through outputs. It never injects a data service, never talks to HTTP, and never reaches into global state. Given the same inputs it always renders the same output, which makes it a pure function of its props.
| Aspect | Smart (container) | Dumb (presentational) |
|---|---|---|
| Knows about data sources | Yes — services, stores, HTTP | No |
| Holds business logic | Yes | No (display logic only) |
| Inputs / outputs | Few, often none | Many input() / output() |
| Reusability | Low (feature-specific) | High |
| Change detection | Default | OnPush |
| Testing | Mock services | Pass inputs, assert output |
A dumb component
Dumb components in modern Angular use the signal-based input() and output() functions and run on OnPush change detection. They should be OnPush precisely because their output depends only on their inputs.
import { Component, ChangeDetectionStrategy, input, output } from '@angular/core';
export interface User {
id: number;
name: string;
active: boolean;
}
@Component({
selector: 'app-user-card',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<article class="card" [class.inactive]="!user().active">
<h3>{{ user().name }}</h3>
@if (user().active) {
<span class="badge">Active</span>
} @else {
<button (click)="activate.emit(user().id)">Activate</button>
}
</article>
`,
})
export class UserCardComponent {
// Required input — the component is useless without a user.
user = input.required<User>();
// Emits the user id when the activate button is clicked.
activate = output<number>();
}
Notice there is no inject(), no HttpClient, and no service of any kind. The component asks for a User and announces an activate intent. That is its entire contract.
A smart component
The container injects the data service, exposes signals for the template, and translates the dumb component’s outputs into real side effects.
import { Component, inject } from '@angular/core';
import { UserCardComponent, User } from './user-card.component';
import { UserService } from './user.service';
@Component({
selector: 'app-user-list',
imports: [UserCardComponent],
template: `
@for (user of users(); track user.id) {
<app-user-card [user]="user" (activate)="onActivate($event)" />
} @empty {
<p>No users found.</p>
}
`,
})
export class UserListComponent {
private readonly userService = inject(UserService);
// Signal exposed to the template; the service owns the source of truth.
readonly users = this.userService.users;
onActivate(id: number): void {
this.userService.activate(id);
}
}
The supporting service holds the state as a signal so the smart component stays thin:
import { Injectable, signal } from '@angular/core';
import { User } from './user-card.component';
@Injectable({ providedIn: 'root' })
export class UserService {
private readonly _users = signal<User[]>([
{ id: 1, name: 'Ada Lovelace', active: false },
{ id: 2, name: 'Alan Turing', active: true },
]);
readonly users = this._users.asReadonly();
activate(id: number): void {
this._users.update((list) =>
list.map((u) => (u.id === id ? { ...u, active: true } : u)),
);
}
}
Why this pays off in tests
Because the dumb component is pure, testing it needs no TestBed providers and no HTTP mocking — you set inputs and assert on rendered output or emitted events.
import { TestBed } from '@angular/core/testing';
import { UserCardComponent } from './user-card.component';
it('emits the user id when activate is clicked', () => {
const fixture = TestBed.createComponent(UserCardComponent);
fixture.componentRef.setInput('user', { id: 7, name: 'Grace', active: false });
let emitted: number | undefined;
fixture.componentInstance.activate.subscribe((id) => (emitted = id));
fixture.detectChanges();
fixture.nativeElement.querySelector('button').click();
expect(emitted).toBe(7);
});
Output:
PASS user-card.component.spec.ts
✓ emits the user id when activate is clicked (24 ms)
Tip: A reliable smell test — if a component injects a service and has more than a few lines of template markup, it is probably doing two jobs. Split the rendering into a dumb child.
When not to split
The pattern adds indirection, so don’t apply it dogmatically. A tiny one-off widget used in a single place gains nothing from being split into a container and a presenter. Reach for the split when a view is reused in more than one context, when a template grows large, or when you want to test rendering logic in isolation from data fetching.
Warning: Avoid the “passthrough container” anti-pattern where a smart component only forwards inputs and re-emits outputs without adding orchestration. If the container holds no logic, you have added a layer for nothing — collapse it.
Best practices
- Make every dumb component
OnPushand give it onlyinput()/output()members — no injected data services. - Keep smart components thin: inject, expose signals, translate outputs into side effects, and delegate rendering.
- Push shared state into a service or store so multiple containers can read the same source of truth.
- Always
trackitems in@forloops so list re-renders stay cheap when smart data changes. - Prefer required inputs (
input.required<T>()) to encode a dumb component’s contract explicitly. - Test dumb components by setting inputs and asserting outputs; test smart components against mocked services.
- Don’t introduce a container that adds no orchestration — collapse passthrough wrappers.