Search

Angular Component Patterns: Smart/Dumb, Container/Presenter

Angular Component Patterns: Smart/Dumb, Container/Presenter

Mình từng làm trong một project Angular mà mỗi component đều inject 5-6 service, vừa fetch data, vừa handle form, vừa navigate, vừa render table phức tạp. File component nào cũng 400-500 dòng. Test? Không ai viết được vì mock 6 service cho một component thì setup dài hơn test thật. Reuse? Không thể vì component nào cũng gắn chặt vào business logic cụ thể.

Vấn đề không phải Angular — mà là cách tổ chức component. Khi một component vừa biết cách lấy data, vừa biết cách hiển thị data, vừa biết navigate đi đâu sau khi user click — nó đang làm quá nhiều việc. Đổi UI thì sợ break logic. Đổi logic thì sợ break UI.

Smart/Dumb pattern (hay Container/Presenter) giải quyết vấn đề này bằng một nguyên tắc đơn giản: tách component thành hai loại — một loại biết lấy data ở đâu, một loại biết render data thế nào. Không bao giờ trộn.

Hai loại component

Smart Component (Container)

Biết mọi thứ về data: inject service, gọi store, handle routing, quản lý state. Nhưng template gần như không có HTML thật — nó chỉ gọi child components và truyền data xuống.

// order-list.page.ts — SMART component
@Component({
  selector: 'app-order-list-page',
  standalone: true,
  imports: [OrderTableComponent, OrderFiltersComponent, PaginationComponent],
  template: `
    <h1>Đơn hàng</h1>

    <app-order-filters
      [categories]="categories()"
      [currentFilter]="store.filter()"
      (filterChange)="store.setFilter($event)" />

    <app-order-table
      [orders]="store.filteredOrders()"
      [loading]="store.loading()"
      (selectOrder)="onSelectOrder($event)"
      (deleteOrder)="onDeleteOrder($event)" />

    <app-pagination
      [currentPage]="store.page()"
      [totalPages]="store.totalPages()"
      (pageChange)="store.setPage($event)" />
  `,
  // không có styleUrl — container không style gì cả
})
export class OrderListPage {
  private router = inject(Router);
  store = inject(OrderStore);
  categories = inject(CategoryStore).categories;

  constructor() {
    this.store.loadOrders();
  }

  onSelectOrder(orderId: string) {
    this.router.navigate(['/orders', orderId]);
  }

  onDeleteOrder(orderId: string) {
    this.store.deleteOrder(orderId);
  }
}

Nhìn vào template: không có *ngFor, không có <table>, không có CSS class. Nó chỉ truyền data xuống và nhận event lên. Mọi quyết định "data lấy từ đâu, xử lý thế nào" nằm trong class.

Dumb Component (Presenter)

Không biết gì về service, store, hay router. Nó nhận data qua @Input(), hiển thị lên UI, và khi user tương tác thì emit event qua @Output(). Vậy thôi.

// order-table.component.ts — DUMB component
@Component({
  selector: 'app-order-table',
  standalone: true,
  imports: [DatePipe, CurrencyPipe],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @if (loading) {
      <div class="skeleton-table">
        @for (i of [1,2,3,4,5]; track i) {
          <div class="skeleton-row"></div>
        }
      </div>
    } @else if (orders.length === 0) {
      <div class="empty-state">
        <p>Không có đơn hàng nào</p>
      </div>
    } @else {
      <table class="order-table">
        <thead>
          <tr>
            <th>Mã đơn</th>
            <th>Khách hàng</th>
            <th>Tổng tiền</th>
            <th>Trạng thái</th>
            <th>Ngày tạo</th>
            <th></th>
          </tr>
        </thead>
        <tbody>
          @for (order of orders; track order.id) {
            <tr (click)="selectOrder.emit(order.id)"
                class="clickable-row">
              <td>{{ order.orderNumber }}</td>
              <td>{{ order.customerName }}</td>
              <td>{{ order.total | currency:'VND' }}</td>
              <td>
                <app-status-badge [status]="order.status" />
              </td>
              <td>{{ order.createdAt | date:'dd/MM/yyyy' }}</td>
              <td>
                <button (click)="onDelete($event, order.id)"
                        class="btn-delete">
                  Xóa
                </button>
              </td>
            </tr>
          }
        </tbody>
      </table>
    }
  `,
  styleUrl: './order-table.component.scss',
})
export class OrderTableComponent {
  @Input({ required: true }) orders: Order[] = [];
  @Input() loading = false;

  @Output() selectOrder = new EventEmitter<string>();
  @Output() deleteOrder = new EventEmitter<string>();

  onDelete(event: Event, orderId: string) {
    event.stopPropagation(); // không trigger row click
    this.deleteOrder.emit(orderId);
  }
}

Không có inject() nào. Không có service nào. Component này chỉ biết "tôi nhận mảng orders, tôi render table, tôi emit khi user click". Nó không biết orders lấy từ đâu, cũng không biết click rồi đi đâu.

Tại sao phải tách?

1. Test dễ hơn gấp nhiều lần

Test dumb component cực kỳ đơn giản — không mock gì cả:

describe('OrderTableComponent', () => {
  let component: OrderTableComponent;
  let fixture: ComponentFixture<OrderTableComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [OrderTableComponent],
    }).compileComponents();

    fixture = TestBed.createComponent(OrderTableComponent);
    component = fixture.componentInstance;
  });

  it('should render rows for each order', () => {
    component.orders = [
      mockOrder({ id: '1', orderNumber: 'ORD-001' }),
      mockOrder({ id: '2', orderNumber: 'ORD-002' }),
    ];
    fixture.detectChanges();

    const rows = fixture.nativeElement.querySelectorAll('tbody tr');
    expect(rows.length).toBe(2);
  });

  it('should show empty state when no orders', () => {
    component.orders = [];
    fixture.detectChanges();

    const emptyState = fixture.nativeElement.querySelector('.empty-state');
    expect(emptyState).toBeTruthy();
  });

  it('should show skeleton when loading', () => {
    component.loading = true;
    fixture.detectChanges();

    const skeleton = fixture.nativeElement.querySelector('.skeleton-table');
    expect(skeleton).toBeTruthy();
  });

  it('should emit selectOrder when row clicked', () => {
    const spy = jest.spyOn(component.selectOrder, 'emit');
    component.orders = [mockOrder({ id: '123' })];
    fixture.detectChanges();

    const row = fixture.nativeElement.querySelector('tbody tr');
    row.click();

    expect(spy).toHaveBeenCalledWith('123');
  });
});

Không HttpClientTestingModule. Không RouterTestingModule. Không mock store. Truyền data vào, kiểm tra output ra. Đó là unit test đúng nghĩa.

So sánh với test component monolithic — bạn phải mock OrderService, Router, AuthService, NotificationService, setup fake HTTP responses, configure routing... Setup dài hơn test gấp 3 lần.

2. Reuse tự nhiên

OrderTableComponent render bất kỳ mảng Order[] nào — không quan tâm data đến từ đâu. Bạn dùng nó ở trang "Đơn hàng của tôi" hay "Admin quản lý đơn hàng" hay "Dashboard đơn hàng gần nhất" — cùng một component, khác data.

// trang admin — data từ AdminOrderStore
template: `<app-order-table [orders]="adminStore.orders()" />`

// trang user — data từ UserOrderStore
template: `<app-order-table [orders]="userStore.myOrders()" />`

// dashboard — data từ DashboardStore, chỉ 5 đơn gần nhất
template: `<app-order-table [orders]="dashStore.recentOrders()" />`

Ba context khác nhau, cùng một dumb component. Nếu OrderTableComponent inject OrderService trực tiếp, bạn không reuse được — phải tạo 3 component khác nhau cho 3 trang.

3. OnPush Change Detection miễn phí

Dumb component chỉ phụ thuộc @Input() — hoàn hảo cho OnPush:

changeDetection: ChangeDetectionStrategy.OnPush

Angular chỉ re-render khi input reference thay đổi. Không scan toàn bộ component tree mỗi cycle. Với table 1.000 row, sự khác biệt performance rõ rệt.

Smart component thường giữ Default change detection vì nó subscribe observables, handle side effects — cần Angular detect changes linh hoạt hơn. Nhưng vì smart component template nhẹ (chỉ gọi child), cost thấp.

4. Team chia việc dễ hơn

Designer hoặc junior dev có thể làm dumb component — chỉ cần biết HTML/CSS và Input/Output contract. Senior dev làm smart component — xử lý logic, state, navigation.

Review PR cũng nhanh hơn: thấy dumb component thì review UI, thấy smart component thì review logic. Không cần đọc 500 dòng mix lẫn lộn.

Thêm ví dụ: Product feature

Smart: ProductListPage

@Component({
  selector: 'app-product-list-page',
  standalone: true,
  imports: [
    SearchBarComponent,
    ProductGridComponent,
    CategorySidebarComponent,
    PaginationComponent,
  ],
  template: `
    <div class="product-layout">
      <aside>
        <app-category-sidebar
          [categories]="categories()"
          [selected]="store.selectedCategory()"
          (select)="store.setCategory($event)" />
      </aside>

      <main>
        <app-search-bar
          [value]="store.searchTerm()"
          (search)="store.search($event)" />

        <app-product-grid
          [products]="store.filteredProducts()"
          [loading]="store.loading()"
          (addToCart)="onAddToCart($event)"
          (viewDetail)="onViewDetail($event)" />

        <app-pagination
          [currentPage]="store.page()"
          [totalPages]="store.totalPages()"
          (pageChange)="store.setPage($event)" />
      </main>
    </div>
  `,
})
export class ProductListPage {
  store = inject(ProductStore);
  categories = inject(CategoryStore).categories;
  private cartStore = inject(CartStore);
  private router = inject(Router);

  onAddToCart(productId: string) {
    this.cartStore.addItem(productId);
  }

  onViewDetail(productId: string) {
    this.router.navigate(['/products', productId]);
  }
}

Dumb: ProductGrid

@Component({
  selector: 'app-product-grid',
  standalone: true,
  imports: [ProductCardComponent],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @if (loading) {
      <div class="grid">
        @for (i of skeletons; track i) {
          <div class="product-card-skeleton"></div>
        }
      </div>
    } @else {
      <div class="grid">
        @for (product of products; track product.id) {
          <app-product-card
            [product]="product"
            (addToCart)="addToCart.emit(product.id)"
            (viewDetail)="viewDetail.emit(product.id)" />
        }
      </div>
    }
  `,
})
export class ProductGridComponent {
  @Input({ required: true }) products: Product[] = [];
  @Input() loading = false;
  @Output() addToCart = new EventEmitter<string>();
  @Output() viewDetail = new EventEmitter<string>();

  skeletons = Array.from({ length: 8 }, (_, i) => i);
}

Dumb: ProductCard

@Component({
  selector: 'app-product-card',
  standalone: true,
  imports: [CurrencyPipe],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="card" (click)="viewDetail.emit()">
      <img [src]="product.imageUrl"
           [alt]="product.name"
           loading="lazy" />
      <div class="card-body">
        <h3>{{ product.name }}</h3>
        <p class="price">{{ product.price | currency:'VND' }}</p>
        <button (click)="onAddToCart($event)">
          Thêm vào giỏ
        </button>
      </div>
    </div>
  `,
  styleUrl: './product-card.component.scss',
})
export class ProductCardComponent {
  @Input({ required: true }) product!: Product;
  @Output() addToCart = new EventEmitter<void>();
  @Output() viewDetail = new EventEmitter<void>();

  onAddToCart(event: Event) {
    event.stopPropagation();
    this.addToCart.emit();
  }
}

Cấu trúc tree: ProductListPage (smart) → ProductGrid (dumb) → ProductCard (dumb). Data chảy xuống qua @Input, events chảy lên qua @Output. Mỗi component làm đúng một việc.

Signal inputs — cú pháp mới gọn hơn

Từ Angular 17+, dumb component dùng signal inputs đọc gọn hơn:

@Component({
  selector: 'app-status-badge',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <span [class]="'badge badge-' + colorClass()">
      {{ label() }}
    </span>
  `,
})
export class StatusBadgeComponent {
  status = input.required<OrderStatus>();

  label = computed(() => {
    return this.status() === 'pending' ? 'Chờ xử lý'
         : this.status() === 'processing' ? 'Đang xử lý'
         : this.status() === 'shipped' ? 'Đang giao'
         : this.status() === 'delivered' ? 'Đã giao'
         : 'Đã hủy';
  });

  colorClass = computed(() => {
    return this.status() === 'delivered' ? 'success'
         : this.status() === 'cancelled' ? 'danger'
         : 'warning';
  });
});

input.required<T>() thay @Input({ required: true }). Kết hợp computed() — derived state từ input, tự update khi input thay đổi. Không cần ngOnChanges.

Output dùng function — Angular 17.3+

// mới — output() function
export class ProductCardComponent {
  product = input.required<Product>();
  addToCart = output<void>();
  viewDetail = output<void>();

  onAddToCart(event: Event) {
    event.stopPropagation();
    this.addToCart.emit();
  }
}

output() thay @Output() + EventEmitter. Gọn hơn một chút, type-safe hơn.

Khi nào KHÔNG cần pattern này

Smart/Dumb không phải silver bullet. Có những lúc tách ra chỉ thêm phức tạp:

Component nhỏ, chỉ dùng một chỗ. Nếu component chỉ 50 dòng, chỉ dùng ở một trang duy nhất, và logic đơn giản — nhét hết vào một component ổn. Đừng tạo 3 file cho một cái button đặc biệt.

Form phức tạp. ReactiveForms quản lý state nội bộ rất tốt. Tách form thành smart container + dumb form fields đôi khi gây rắc rối hơn — FormGroup truyền qua nhiều layer Input/Output rất awkward. Thường mình giữ form trong một component, chỉ tách sub-forms lớn.

Prototype / MVP. Đang validate idea, cần ship nhanh? Code monolithic trước, refactor pattern sau khi biết feature sẽ tồn tại lâu dài. Đừng architect quá mức cho thứ có thể bị xóa tuần sau.

Component library. Nếu bạn xây shared UI library, tất cả đã là dumb component rồi — chúng không biết business logic. Smart component chỉ tồn tại ở application layer.

Folder structure thực tế

src/app/features/orders/
├── pages/
│   ├── order-list.page.ts          ← Smart (container)
│   └── order-detail.page.ts        ← Smart (container)
├── components/
│   ├── order-table.component.ts     ← Dumb (presenter)
│   ├── order-table.component.scss
│   ├── order-filters.component.ts   ← Dumb
│   ├── order-status-badge.component.ts  ← Dumb
│   └── order-summary-card.component.ts  ← Dumb
├── stores/
│   └── order.store.ts
├── models/
│   └── order.model.ts
└── order.routes.ts

Naming convention: smart component đặt trong pages/, tên có suffix .page.ts. Dumb component đặt trong components/, tên có suffix .component.ts. Nhìn vào file name biết ngay component thuộc loại nào.

Routing chỉ trỏ đến page components:

// order.routes.ts
export const ORDER_ROUTES: Routes = [
  {
    path: '',
    component: OrderListPage,        // smart
  },
  {
    path: ':id',
    component: OrderDetailPage,      // smart
  },
];

Dumb component không bao giờ là route target — nó không biết route, không biết params.

Anti-patterns cần tránh

Prop drilling quá sâu

PageComponent → LayoutComponent → SidebarComponent → MenuComponent → MenuItemComponent

Truyền data qua 5 tầng Input/Output là dấu hiệu sai. Giải pháp: hoặc flatten component tree, hoặc dùng shared store/signal mà component giữa không cần biết.

Dumb component quá thông minh

// ❌ "dumb" nhưng thực ra inject service
export class OrderTableComponent {
  private translateService = inject(TranslateService); // không nên
  @Input() orders: Order[] = [];
}

Nếu dumb component cần translate, truyền text đã translate qua Input. Hoặc dùng pipe trong template. Inject service là boundary violation.

Smart component quá nhiều

// ❌ smart component nhưng template dài 200 dòng HTML
template: `
  <div class="header">...</div>
  <div class="filters">
    <select>...</select>
    <input>...</input>
  </div>
  <table>
    <thead>...</thead>
    <tbody>
      @for (item of items; track item.id) {
        <tr>... 20 dòng HTML ...</tr>
      }
    </tbody>
  </table>
  <div class="pagination">...</div>
`

Nếu smart component template có HTML phức tạp — nó đang làm cả hai việc. Tách UI ra dumb component.

Output chain quá dài

// child emit → parent emit → grandparent handle
// 3 tầng emit cho một click event

Nếu event cần bubble qua 3+ tầng, cân nhắc dùng shared signal/store thay vì chain Output. Dumb component emit lên parent trực tiếp — nếu parent cũng chỉ re-emit, đó là code smell.

Checklist nhanh

Khi tạo component mới, hỏi:

Component này inject service không?

  • Có → Smart. Đặt trong pages/.
  • Không → Dumb. Đặt trong components/.

Component này biết router không?

  • Có → Smart.
  • Không → Dumb.

Component này có thể reuse ở context khác không?

  • Có → Dumb. Chỉ dùng Input/Output.
  • Không → Có thể smart. Nhưng verify lại — thường có phần UI tách dumb được.

Template có HTML phức tạp (table, form, card) không?

  • Có → Dumb. Nó là presenter.
  • Không, chỉ gọi child components → Smart. Nó là container.

Tổng kết

Smart/Dumb pattern không phải lý thuyết academic — nó là cách tổ chức code thực tế giúp Angular app dễ test, dễ reuse, dễ maintain. Nguyên tắc chỉ có một: component hoặc biết lấy data ở đâu (smart), hoặc biết render data thế nào (dumb). Không bao giờ cả hai.

Bắt đầu bằng cách nhìn component hiện tại: nó có vừa inject service vừa render table phức tạp không? Nếu có, tách. Extract phần UI ra dumb component, giữ phần logic trong smart component. Một component 500 dòng trở thành hai component 150 dòng — mỗi cái đọc hiểu trong 30 giây thay vì 5 phút.

Đừng refactor hết codebase cùng lúc. Áp dụng cho feature mới, refactor dần feature cũ khi đụng vào. Theo thời gian, codebase tự trở nên gọn gàng hơn.

Culi Dev

Culi Dev

Enjoy coding, enjoy life!

Leave a comment

Your email address will not be published. Required fields are marked *

Your experience on this site will be improved by allowing cookies Cookie Policy