As Angular applications grow, components without a clear design philosophy become bloated, impossible to test, and hard to reuse. The Smart/Presentational pattern (also called Container/Presentational or Smart/Dumb) is the most widely-used component architecture in production Angular applications. It divides components into two categories with clearly defined responsibilities: smart components know about services, state, and the application’s data layer; presentational components only know how to display what they are given and emit user interactions back up. This separation produces components that are independently testable, highly reusable, and easy to reason about.
Smart vs Presentational โ Side-by-Side
| Aspect | Smart Component (Container) | Presentational Component |
|---|---|---|
| Also called | Container, Connected, Stateful | Dumb, Pure, Stateless, UI |
| Knows about services | Yes โ injects TaskService, AuthService, Router | No โ never injects application services |
| Knows about state | Yes โ owns or subscribes to application state | No โ receives state via @Input() |
| Communicates via | Services, Router, Signals/State stores | @Input() and @Output() only |
| Reusability | Low โ tied to this application’s data layer | High โ can be used in any context |
| Testing | Needs mocked services | Pure โ just pass inputs, assert outputs |
| Typical location | features/tasks/task-list/ (page-level) |
shared/components/ or feature UI components |
| Examples | TaskListPage, LoginPage, DashboardPage | TaskCard, PriorityBadge, Button, Modal |
Signals and the Modern Pattern
| Pattern | Smart Component Approach |
|---|---|
| State | Owns signals: tasks = signal<Task[]>([]) |
| Data loading | Calls service in ngOnInit(), sets signals from Observable |
| Derived state | Uses computed() for filtered/sorted views |
| Child communication | Passes signal values as @Input, handles @Output events |
| Actions | Calls service methods, updates signals on success |
TaskCardComponent that takes a Task as @Input() can be used in a list page, a dashboard widget, a search result, and a detail sidebar โ all without knowing where the task data came from.(deleted)="onDelete($event)" and let the smart parent component call the service and update state. If the card calls the service directly, you can no longer reuse it in read-only contexts (like an archive view) without worrying about accidental deletions.Complete Pattern Implementation
// โโ SMART COMPONENT โ task-list page โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// features/tasks/task-list/task-list.component.ts
import {
Component, OnInit, signal, computed, inject
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { TaskService } from '../../../core/services/task.service';
import { Task } from '../../../shared/models/task.model';
import { TaskCardComponent } from '../task-card/task-card.component';
import { TaskFilterComponent } from '../task-filter/task-filter.component';
import { SpinnerComponent } from '../../../shared/components/spinner/spinner.component';
@Component({
selector: 'app-task-list',
standalone: true,
imports: [CommonModule, TaskCardComponent, TaskFilterComponent, SpinnerComponent],
templateUrl:'./task-list.component.html',
})
export class TaskListComponent implements OnInit {
// โโ Services โ smart component owns these โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
private taskService = inject(TaskService);
private router = inject(Router);
// โโ State โ smart component owns all state โโโโโโโโโโโโโโโโโโโโโโโโโโโโ
tasks = signal<Task[]>([]);
filterStatus = signal<string>('');
loading = signal(true);
error = signal<string | null>(null);
deletingId = signal<string | null>(null);
// โโ Derived state via computed() โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
filteredTasks = computed(() => {
const s = this.filterStatus();
return s ? this.tasks().filter(t => t.status === s) : this.tasks();
});
taskCounts = computed(() => ({
total: this.tasks().length,
pending: this.tasks().filter(t => t.status === 'pending').length,
active: this.tasks().filter(t => t.status === 'in-progress').length,
completed: this.tasks().filter(t => t.status === 'completed').length,
}));
ngOnInit(): void {
this.taskService.getAll()
.pipe(takeUntilDestroyed())
.subscribe({
next: tasks => { this.tasks.set(tasks); this.loading.set(false); },
error: err => { this.error.set(err.message); this.loading.set(false); },
});
}
// โโ Event handlers โ called when presentational children emit events โโ
onFilterChange(status: string): void {
this.filterStatus.set(status);
}
onTaskComplete(taskId: string): void {
this.taskService.complete(taskId).subscribe({
next: updated => {
this.tasks.update(tasks =>
tasks.map(t => t._id === taskId ? updated : t)
);
},
});
}
onTaskDelete(taskId: string): void {
this.deletingId.set(taskId);
this.taskService.delete(taskId).subscribe({
next: () => {
this.tasks.update(tasks => tasks.filter(t => t._id !== taskId));
this.deletingId.set(null);
},
error: () => this.deletingId.set(null),
});
}
onTaskEdit(taskId: string): void {
this.router.navigate(['/tasks', taskId, 'edit']);
}
trackById = (_: number, task: Task): string => task._id;
}
<!-- task-list.component.html โ smart component template -->
<div class="task-list-page">
<!-- Stats passed to child as data โ child has no idea where they come from -->
<app-task-filter
[counts]="taskCounts()"
[activeFilter]="filterStatus()"
(filterChange)="onFilterChange($event)">
</app-task-filter>
<app-spinner *ngIf="loading()"></app-spinner>
<p *ngIf="error()" class="error">{{ error() }}</p>
<ul *ngIf="!loading()" class="task-grid">
<li *ngFor="let task of filteredTasks(); trackBy: trackById">
<!-- Presentational child: receives data, emits events -->
<app-task-card
[task]="task"
[isDeleting]="deletingId() === task._id"
(completed)="onTaskComplete($event)"
(deleted)="onTaskDelete($event)"
(edited)="onTaskEdit($event)">
</app-task-card>
</li>
</ul>
</div>
// โโ PRESENTATIONAL COMPONENT โ task card โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// shared/components/task-card/task-card.component.ts
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { CommonModule, DatePipe } from '@angular/common';
import { Task } from '../../models/task.model';
@Component({
selector: 'app-task-card',
standalone: true,
imports: [CommonModule, DatePipe],
templateUrl:'./task-card.component.html',
styleUrl: './task-card.component.scss',
})
export class TaskCardComponent {
// โโ Only @Input and @Output โ no injected services โโโโโโโโโโโโโโโโโโโโ
@Input() task!: Task;
@Input() isDeleting = false;
@Output() completed = new EventEmitter<string>();
@Output() deleted = new EventEmitter<string>();
@Output() edited = new EventEmitter<string>();
get isOverdue(): boolean {
return !!this.task.dueDate
&& new Date(this.task.dueDate) < new Date()
&& this.task.status !== 'completed';
}
get priorityClass(): string {
return `task-card--${this.task.priority}`;
}
// These emit events upward โ parent decides what to do
markComplete(): void { this.completed.emit(this.task._id); }
requestDelete(): void { this.deleted.emit(this.task._id); }
requestEdit(): void { this.edited.emit(this.task._id); }
}
<!-- task-card.component.html โ pure UI, no services, no routing -->
<article class="task-card" [ngClass]="priorityClass"
[class.task-card--overdue]="isOverdue"
[class.task-card--completed]="task.status === 'completed'">
<header class="task-card__header">
<h3 class="task-card__title">{{ task.title }}</h3>
<span class="badge badge--{{ task.status }}">{{ task.status | titlecase }}</span>
</header>
<p *ngIf="task.description" class="task-card__description">
{{ task.description }}
</p>
<footer class="task-card__footer">
<time *ngIf="task.dueDate" [class.overdue]="isOverdue">
Due: {{ task.dueDate | date:'mediumDate' }}
</time>
<div class="task-card__actions">
<button (click)="markComplete()"
[disabled]="task.status === 'completed'">
Complete
</button>
<button (click)="requestEdit()">Edit</button>
<button (click)="requestDelete()"
[disabled]="isDeleting"
class="btn--danger">
{{ isDeleting ? 'Deleting...' : 'Delete' }}
</button>
</div>
</footer>
</article>
How It Works
Step 1 โ Smart Components Are the Data Boundary
The smart component is the boundary between the application’s data layer (services, API, state) and the UI layer (templates, child components). It fetches data, subscribes to state changes, handles user action outcomes, and updates state. Everything the presentational tree below it needs comes through @Input(). Everything it needs from the UI comes back through @Output() events. This makes the data flow explicit and unidirectional.
Step 2 โ Presentational Components Are Pure Functions of Their Inputs
Given the same inputs, a presentational component always produces the same view. This deterministic behaviour is what makes them so easy to test โ no mocking required. Pass a Task object and assert the rendered HTML contains the expected title, badge colour, and button state. The test does not need to mock TaskService, set up an HTTP testing module, or configure the router because the component has no dependency on any of those.
Step 3 โ Events Flow Up, Data Flows Down
Data always flows down through @Input() from parent to child. Events always flow up through @Output() from child to parent. This unidirectional data flow is predictable โ you can always trace where state changes originate. When something in the UI changes (a task is completed), you follow the event chain upward to the smart component, which is the only place state actually changes.
Step 4 โ Smart Components Handle Async, Presentational Components Do Not
Loading states, error states, and pending actions are the smart component’s responsibility. The isDeleting input to TaskCardComponent lets the card show a loading state without knowing anything about HTTP calls. The card just renders based on what it is told. The smart parent manages the async operation and tells the card whether it is in a deleting state by setting the input.
Step 5 โ The Pattern Scales to Complex UIs
A page built from this pattern is a tree: one smart root component at the top, a tree of presentational components below it. As the page grows, you add more presentational components without touching the smart component’s service calls. As requirements change, you swap out presentational components with different visual designs without touching the data layer. The two concerns are completely decoupled.
Common Mistakes
Mistake 1 โ Injecting services into presentational components
โ Wrong โ task card calls the service directly:
export class TaskCardComponent {
@Input() task!: Task;
constructor(private taskService: TaskService) {} // service injection in presentational!
delete(): void {
this.taskService.delete(this.task._id).subscribe(...); // makes card untestable without mock
}
}
✅ Correct โ emit the event, let the smart parent handle it:
export class TaskCardComponent {
@Input() task!: Task;
@Output() deleted = new EventEmitter<string>();
delete(): void { this.deleted.emit(this.task._id); }
}
Mistake 2 โ Smart component doing its own rendering
โ Wrong โ smart component handles both data AND all rendering details:
// 300-line component with service calls, template logic, CSS classes, AND data
export class TaskListComponent {
constructor(private taskService: TaskService) {}
// ... 200 lines of mixed data + UI logic
}
✅ Correct โ smart component delegates rendering to presentational children:
// Smart: thin โ loads data, handles events, passes to children
// Presentational: handles all rendering details via @Input
Mistake 3 โ Presentational component navigating directly
โ Wrong โ card navigates on edit โ cannot reuse in read-only views:
export class TaskCardComponent {
constructor(private router: Router) {} // Router in presentational!
edit(): void { this.router.navigate(['/tasks', this.task._id, 'edit']); }
}
✅ Correct โ emit the event, smart parent decides where to navigate:
export class TaskCardComponent {
@Output() edited = new EventEmitter<string>();
edit(): void { this.edited.emit(this.task._id); }
}
Quick Reference
| Question | Smart | Presentational |
|---|---|---|
| Injects services? | Yes | No |
| Knows about routing? | Yes (Router) | No |
| Owns state? | Yes (signals) | No |
| Gets data via | Services / state | @Input() |
| Responds to user via | Direct service calls | @Output() events |
| Test requires mocks? | Yes (mock services) | No (just pass inputs) |
| Reusability | Low โ app-specific | High โ generic UI |
| Location | features/ |
shared/components/ |