DEV Community

Cover image for A Senior Dev's Guide to Angular Architecture: Mastering the "Smart vs. Dumb" Component Pattern
Habibur Rahman
Habibur Rahman

Posted on

A Senior Dev's Guide to Angular Architecture: Mastering the "Smart vs. Dumb" Component Pattern

If you asked me to identify the single biggest difference between a junior Angular developer's code and a senior's, I wouldn't point to their knowledge of RxJS operators or obscure TypeScript features.

I'd point to their component architecture.

When I audit a codebase and see a single UserProfileComponent that is 800 lines long, injects five different services, manages complex loading states, and also contains 300 lines of CSS and HTML structure, I know I'm looking at a maintenance nightmare waiting to happen.

This is the "God Component" anti-pattern. It knows too much, does too much, and is nearly impossible to test or reuse.

The industry-standard antidote to this—and the architecture pattern that changed my career—is the Smart (Container) vs. Dumb (Presentational) separation.

It’s not just a neat trick; it’s a fundamental shift in how you build applications.


The Core Philosophy

The goal is to decouple how things work (Logic/State) from how things look (UI/Rendering).

If you mix these two, you create components that are:

  1. Brittle: A change in API structure breaks your UI code.
  2. Non-Reusable: You can't use your user card in another part of the app because it's tightly coupled to a specific service call.
  3. Hard to Test: To test the UI, you have to mock complex services and observable streams.

Let's break down the two types of components that solve this.

1. The "Dumb" Component (Presentational)

I prefer the term "Presentational," but "Dumb" sticks in the brain better.

Think of a Dumb component as a pure function. In functional programming, a pure function takes an input and returns an output, without any side effects. A Dumb component takes data as input (@Input) and renders UI as output (its template).

The Golden Rules of Dumb Components:

  • Only receives data via @Input(). It has no idea where the data came from.
  • Only communicates via @Output(). It doesn't call service methods; it emits events when things happen (like a button click).
  • Has ZERO injectable dependencies. Its constructor should be empty. No HttpClient, no Router, no Store.
  • Responsible for UI logic only. It can handle things like "is this button disabled based on the input?" or a simple CSS toggle.

Example: A Reusable User Card

This component is beautifully dumb. It can be dropped anywhere in your application.

import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core';
import { User } from './user.model';

@Component({
  selector: 'app-user-card',
  template: `
    <div class="user-card" [class.premium]="user.isPremium">
      <img [src]="user.avatarUrl" alt="{{ user.name }}'s avatar">
      <div class="details">
        <h3>{{ user.name }}</h3>
        <p>{{ user.email }}</p>
      </div>
      <button (click)="onDeleteClick()">Delete User</button>
    </div>
  `,
  styles: [`
    .user-card { border: 1px solid #ccc; padding: 16px; display: flex; }
    .premium { border-color: gold; }
    // ... more styles
  `],
  // Dumb components are perfect candidates for OnPush
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserCardComponent {
  // Data goes IN
  @Input({ required: true }) user!: User;

  // Events go OUT
  @Output() delete = new EventEmitter<number>();

  onDeleteClick() {
    // It doesn't know HOW to delete, just emits the ID
    this.delete.emit(this.user.id);
  }
}
Enter fullscreen mode Exit fullscreen mode

2. The "Smart" Component (Container)

This is the manager. The orchestrator. It usually corresponds to a route (a page).

Its job is to connect the application's business logic to the Presentational components. It doesn't care about CSS classes or HTML structure; it cares about data streams.

The Golden Rules of Smart Components:

  • Injects Services. This is where your DataService, Store, or ActivatedRoute live.
  • Manages State Streams. It holds the Observable<User[]> that will feed the UI.
  • Handles Events. It implements the functions that the Dumb component's @Outputs will call.
  • Has very little (or no) styles.

Example: The User List Page

This component is the bridge.

import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { UserService } from './user.service';
import { UserCardComponent } from './user-card.component';

@Component({
  standalone: true,
  // Imports the dumb component
  imports: [CommonModule, UserCardComponent],
  template: `
    <div class="page-container">
      <h1>All Users</h1>

      <div *ngIf="isLoading$ | async">Loading users...</div>

      <div *ngIf="error$ | async as error" class="error">{{ error }}</div>

      <div class="user-grid">
        <app-user-card
          *ngFor="let user of users$ | async"
          [user]="user"
          (delete)="handleDeleteUser($event)">
        </app-user-card>
      </div>
    </div>
  `
})
export class UserListPageComponent {
  private userService = inject(UserService);

  // State streams
  users$ = this.userService.users$;
  isLoading$ = this.userService.loading$;
  error$ = this.userService.error$;

  constructor() {
    // Initial data fetch
    this.userService.loadAll();
  }

  // The actual business logic for deleting
  handleDeleteUser(userId: number) {
    console.log('Smart component received delete request for:', userId);
    // Call the service to perform the actual API call
    this.userService.delete(userId).subscribe();
  }
}
Enter fullscreen mode Exit fullscreen mode

The Massive Benefits of This Approach

Moving to this architecture isn't just academic; it has real-world payoff:

1. Trivial Unit Testing

Testing the UserCardComponent is laughable easy. You don't need TestBed or mock services. You just instantiate the class, set the @Input user, click the button, and spy on the @Output emitter. Done.

2. True Reusability

Imagine your boss comes in and says, "We need a 'Recently Viewed Users' sidebar on the dashboard."

  • Old Way: You copy-paste the HTML and CSS from the main user list component and hack out the parts you don't need. Now you have two places to maintain that UI.
  • Smart/Dumb Way: You create a new Smart component (RecentUsersSidebarComponent), inject the service to get recent users, and reuse the exact same <app-user-card> tag. Done in 10 minutes.

3. Better Performance (OnPush)

Because Dumb components rely only on their inputs, they are perfect candidates for ChangeDetectionStrategy.OnPush. This tells Angular: "Don't check me unless my Input reference changes." This is a massive performance boost in large applications.

Conclusion

The next time you create a component, pause before you inject the HttpClient. Ask yourself: "Is this a UI element, or is this a data manager?"

If it's a UI element, keep it dumb. Force yourself to pass data in via inputs. It feels like extra work at first, but it's the foundation of a scalable, maintainable frontend architecture.

Top comments (0)