Smart vs Presentational Components

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
Note: The smart/presentational split is a guideline, not a rigid rule. In a small feature with only one presentational concern, creating a separate smart and presentational component adds unnecessary indirection. Apply the pattern when a component is doing too much โ€” fetching data AND rendering it โ€” or when you need to reuse the same UI in multiple places with different data sources. Let complexity drive the split, not convention for its own sake.
Tip: The clearest sign you need to split a component is when you find yourself writing the same template multiple times with different data sources, or when testing a component requires mocking five services. Presentational components are ideal for Angular Material or custom design system components: a 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.
Warning: Presentational components should never navigate using the Router or make HTTP calls directly. When a user clicks “Delete” on a task card, the card should emit (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/

🧠 Test Yourself

A TaskCardComponent needs to show a confirmation dialog before deleting. The dialog’s “Confirm” button should trigger the delete API call. Where should the HTTP request be made?