Angular Learning
Component & Template

OnPush Change Detection trong Angular

Hiểu khi nào Angular kiểm tra component OnPush, cách phối hợp input, Signals, AsyncPipe và ChangeDetectorRef để UI luôn cập nhật đúng.

Phạm vi và kiến thức đầu vào

Bài này giải thích ChangeDetectionStrategy.OnPush từ cơ chế đến thực hành. Bạn nên biết component, input và event binding. Ví dụ dùng standalone component và API có trong Angular 18; phần ghi chú phiên bản giúp áp dụng vào Angular mới hơn.

Mục lục

OnPush là gì

OnPush là chiến lược cho phép Angular bỏ qua việc kiểm tra template của component khi component không có lý do cần cập nhật. Nó không có nghĩa “chỉ cập nhật khi input đổi”.

Ví dụ, một bộ đếm không có input vẫn hoạt động với OnPush:

counter.component.ts
import { ChangeDetectionStrategy, Component } from '@angular/core';

@Component({
  selector: 'app-counter',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <p>{{ count }}</p>
    <button type="button" (click)="increase()">+1</button>
  `,
})
export class CounterComponent {
  count = 0;

  increase(): void {
    this.count += 1;
  }
}

Angular biết (click) đã chạy trong view này. Nó đánh dấu component cần kiểm tra, nên count mới xuất hiện trên màn hình dù đây chỉ là một thuộc tính thường của class, hay field.

Ba khái niệm cần tách biệt

Change detection là quá trình Angular đọc expression trong template, so sánh kết quả với lần trước và đồng bộ phần khác biệt vào DOM.

Với {{ count }}, Angular đọc count. Nếu giá trị vẫn là 1, nó không cần ghi lại text node.

Khái niệmÝ nghĩaVí dụ
ViewPhần giao diện Angular quản lý, như template của một componentTemplate chứa {{ count }}
Dirty hoặc cần refreshView đã có dấu hiệu cần được kiểm tra lạiEvent chạy, signal được template đọc thay đổi
DOM updateAngular ghi khác biệt thật vào DOMText đổi từ 1 thành 2

Dirty không đồng nghĩa DOM chắc chắn đổi. Nó chỉ cho Angular một lý do để kiểm tra. OnPush chủ yếu giảm số lượt kiểm tra không cần thiết; component instance không bị tạo lại sau mỗi lần state đổi.

Mô hình từ state đến DOM

Hãy hình dung hai cánh cửa: Angular phải biết cần đồng bộ ứng dụng, rồi phải chọn được view cần kiểm tra. Notification là tín hiệu báo cho Angular rằng có công việc đồng bộ cần xử lý.

direction: right

n0: "State đổi" {
  width: 130
  height: 58
}

n1: "Notification hợp lệ\nEvent, signal,\nAsyncPipe,\nmarkForCheck" {
  width: 176
  height: 100
}

n2: "Kiểm tra view\ncần cập nhật" {
  width: 130
  height: 58
}

n3: "Binding đổi\nGhi khác biệt vào DOM" {
  width: 192
  height: 58
}

n0 -> n1
n1 -> n2
n2 -> n3
State đổiNotification hợp lệEvent, signal,AsyncPipe,markForCheckKiểm tra viewcần cập nhậtBinding đổiGhi khác biệt vào DOM
State đổiNotification hợp lệEvent, signal,AsyncPipe,markForCheckKiểm tra viewcần cập nhậtBinding đổiGhi khác biệt vào DOM
OnPush cần notification để đồng bộ DOM

Dòng this.count = 10 chỉ đổi giá trị trong bộ nhớ. Nó không tự sửa text trong DOM, cũng không tự đánh dấu component OnPush cần kiểm tra. Cùng một dòng gán có thể cho kết quả UI khác nhau tùy nơi nó chạy.

Ví dụ cụ thể: gán trong click và gán trong timer. Component dưới đây bắt đầu với count = 0:

notification-demo.component.ts
import { ChangeDetectionStrategy, Component } from '@angular/core';

@Component({
  selector: 'app-notification-demo',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <p>Count trên UI: {{ count }}</p>
    <button type="button" (click)="setNow()">Gán 10 ngay</button>
    <button type="button" (click)="setLater()">Gán 10 sau 2 giây</button>
  `,
})
export class NotificationDemoComponent {
  count = 0;

  setNow(): void {
    this.count = 10;
  }

  setLater(): void {
    setTimeout(() => {
      this.count = 10;
      console.log('Count trong bộ nhớ:', this.count);
    }, 2_000);
  }
}

Trường hợp 1 — bấm “Gán 10 ngay”. Angular quản lý listener (click) này, nên nó đánh dấu view cần kiểm tra. Handler gán count = 10 ngay trước lượt kiểm tra đó. Template đọc 10 và Angular đổi text trên màn hình từ 0 thành 10.

direction: right

n0: "(click)\nView được đánh dấu" {
  width: 168
  height: 58
}

n1: "count = 10" {
  width: 130
  height: 58
}

n2: "Kiểm tra template" {
  width: 160
  height: 58
}

n3: "UI hiện 10" {
  width: 130
  height: 58
}

n0 -> n1
n1 -> n2
n2 -> n3
(click)View được đánh dấucount = 10Kiểm tra templateUI hiện 10
(click)View được đánh dấucount = 10Kiểm tra templateUI hiện 10
Event đánh dấu view trước khi kiểm tra template

UI cập nhật nhờ notification từ event, không phải vì phép gán field tự có khả năng thông báo.

Trường hợp 2 — tải lại trang, rồi bấm “Gán 10 sau 2 giây”. Đừng thử ngay sau trường hợp 1 mà không reset, vì UI lúc đó đã là 10. Để quan sát lỗi, chờ timer mà không bấm nút khác hoặc tạo notification khác cho view.

Thời điểmViệc xảy racount trong bộ nhớText trên UI
Vừa renderAngular đọc giá trị ban đầu00
Bấm nút đặt trễsetLater() chỉ đăng ký timer; chưa chạy dòng gán00
Lượt kiểm tra sau clickAngular đọc count, rồi view trở về trạng thái sạch00
Sau 2 giâyCallback gán count = 10, console in 1010Vẫn 0 nếu không có lý do khác để kiểm tra view

Điểm dễ nhầm là: click và callback timer là hai lần thực thi khác nhau. Click đánh dấu view cho lượt kiểm tra lúc đó. Nó không đánh dấu view mãi mãi cho mọi callback được đăng ký trong handler.

Sau khi console đã in 10, bấm “Gán 10 ngay”. UI sẽ hiện 10 vì click mới lại đánh dấu view. Lúc này field trong bộ nhớ vốn đã là 10; event mới chỉ giúp Angular đọc lại giá trị đó.

Ngay cả khi timer chạy trong Zone.js

Zone.js có thể làm Angular chạy một lượt change detection khi timer hoàn tất. Nhưng component OnPush đã sạch vẫn có thể bị bỏ qua. Trong zoneless, phép gán field thường trong timer cũng không tự lên lịch kiểm tra. Vì vậy, điều cần bổ sung là notification cho view này, không chỉ mong timer hoặc một tick toàn cục làm thay việc đó.

Cách sửa khi giữ field thường: thêm ChangeDetectorRef và inject vào import từ @angular/core, rồi thay phần xử lý timer trong class bằng:

private readonly cdr = inject(ChangeDetectorRef);

setLater(): void {
  setTimeout(() => {
    this.count = 10;
    this.cdr.markForCheck(); // Báo rõ: view này cần được kiểm tra lại.
  }, 2_000);
}

markForCheck() đánh dấu view để Angular đồng bộ. Nó không ghi DOM ngay tại dòng gọi; UI cập nhật trong lượt kiểm tra được lên lịch sau đó.

Cách sửa bằng signal: import signal từ @angular/core, đổi field và method như sau:

readonly count = signal(0);

setNow(): void {
  this.count.set(10);
}

setLater(): void {
  setTimeout(() => {
    this.count.set(10);
  }, 2_000);
}

Template phải đổi từ {{ count }} sang {{ count() }}. Angular ghi nhận template đọc signal này, nên khi giá trị đổi từ 0 sang 10, signal update thông báo cho view cần cập nhật. Không cần gọi thêm markForCheck() trong callback.

Timer trong ví dụ chỉ dùng để minh họa cơ chế. Với component thực tế, hủy timer khi component bị hủy nếu công việc không còn cần thiết.

Câu hỏi quan trọng: sau thao tác ghi này, cơ chế nào bảo đảm view được kiểm tra? Trong ba cách trên, câu trả lời lần lượt là event Angular quản lý, markForCheck() và signal được template đọc.

Vì sao có change detection mà UI vẫn không đổi

Có một lượt change detection chạy không có nghĩa mọi template đều được đọc lại. Trong ví dụ timer, Angular có thể bắt đầu lượt đồng bộ nhưng bỏ qua template OnPush đang sạch.

Sau callback, hai giá trị vẫn có thể khác nhau:

Field count trong bộ nhớ: 10
Text đang nằm trong DOM:  "0"

DOM không tự theo dõi field JavaScript. Nếu Angular chưa đọc lại {{ count }}, text cũ vẫn nằm trong DOM.

Có thể hình dung quyết định kiểm tra view bằng giả mã sau. isOnPush và needsCheck chỉ là tên minh họa, không phải property công khai của Angular:

if (view.isOnPush && !view.needsCheck) {
  return; // Bỏ qua template: không đọc count, không sửa DOM.
}

readTemplateAndUpdateDOM();

“View sạch” không có nghĩa dữ liệu thật sự chưa đổi. Nó chỉ có nghĩa view chưa có dấu hiệu cần refresh. Angular không gắn cơ chế thông báo tự động vào mọi field thường, nên this.count = 10 không tự đổi trạng thái đó.

Với timer chạy trong Angular zone của app dùng Zone.js, lượt change detection có thể bắt đầu. Nhưng nếu view OnPush vẫn sạch, Angular bỏ qua template trước khi đọc được giá trị 10. Đây là lý do có change detection mà UI vẫn hiện 0.

Khi thêm markForCheck() hoặc dùng signal được template đọc, view có lý do cần refresh. Angular đọc 10, so với binding cũ là 0, rồi sửa DOM thành 10.

Với Default, khi lượt duyệt đi tới view, template thường được đọc lại mà không cần điều kiện đánh dấu riêng của OnPush. Trong zoneless, chỉ gán field trong timer lại không tự lên lịch đồng bộ, kể cả với Default.

Kết luận: hãy kiểm tra cả hai điều kiện: có lượt đồng bộ chạy, và view này có được chọn để kiểm tra hay không.

Cấu hình và khác biệt phiên bản

Cấu hình nằm trong metadata của @Component:

@Component({
  selector: 'app-user-card',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<p>{{ name }}</p>`,
})
export class UserCardComponent {
  name = 'An';
}

OnPush không chỉ dành cho standalone component. Component khai báo trong NgModule cũng dùng cùng thuộc tính changeDetection.

Default và OnPush khác nhau ở đâu

Trong một lượt duyệt thông thường, chiến lược CheckAlways kiểm tra view khi Angular đi tới nó. OnPush cho phép bỏ qua template nếu view không có input mới hoặc notification liên quan.

Khía cạnhCheckAlwaysOnPush
Tên APIDefault trong Angular 18–21; Eager trong Angular v22OnPush
Khi traversal đi tới viewThường kiểm tra templateChỉ kiểm tra khi có điều kiện phù hợp
Input object bị mutate tại chỗTemplate có thể đọc được mutation ở lượt kiểm tra kế tiếpKhông tạo lý do kiểm tra chỉ bằng input đó
Field thường đổi trong timerTrong app dùng Zone.js, tick có thể làm view cập nhậtTick không đủ nếu view vẫn sạch
Chi phí chínhCó thể kiểm tra nhiều view không liên quanBỏ qua được nhiều view không cần cập nhật

Traversal là quá trình Angular đi qua cây view. Đi qua một nhánh để tới view con không nhất thiết có nghĩa template của mọi ancestor đều được đánh giá lại.

CheckAlways cũng không có nghĩa “theo dõi mọi dòng gán JavaScript”. Đặc biệt trong zoneless, ứng dụng vẫn cần notification để lên lịch đồng bộ.

Đọc tài liệu theo đúng phiên bản

Tài liệu cũ thường viết “Default là mặc định, OnPush phải bật thủ công”. Điều đó đúng với Angular 18 mà nhiều khóa học đang dùng, nhưng không còn đúng cho mọi phiên bản.

Phiên bảnChiến lược khi không khai báo changeDetectionGhi chú
Angular 18–21ChangeDetectionStrategy.DefaultKhai báo OnPush rõ ràng để dùng chiến lược này
Angular v22ChangeDetectionStrategy.OnPushEager là tên cho chiến lược kiểm tra chủ động; Default là alias đã deprecated

Các ví dụ trong bài vẫn khai báo OnPush tường minh để thể hiện ý định và dùng được trên các phiên bản khác nhau.

Đừng trộn strategy với Zone.js

Angular v21+ dùng zoneless mặc định, trừ khi ứng dụng cấu hình lại để dùng Zone.js. Đây là một thay đổi khác với việc OnPush trở thành mặc định ở v22. Strategy quyết định view nào cần kiểm tra; Zone.js hoặc scheduler zoneless — bộ lập lịch đồng bộ — liên quan đến lúc Angular được đánh thức để đồng bộ.

Khi nào component OnPush được kiểm tra

Component OnPush được khởi tạo và render lần đầu như bình thường. Sau đó, các cơ chế phổ biến sau giúp view được kiểm tra lại:

Cơ chếVí dụĐiều cần nhớ
Input mới qua binding<app-card [user]="user" /> nhận object mớiBinding của cha phải được đánh giá để Angular biết input đổi
Event Angular xử lý(click), output binding, @HostListenerEvent trong component hoặc các view con liên quan có thể đánh dấu đường ancestor
Signal được template đọc thay đổicount.update(...) với {{ count() }}Dependency phải được template đọc; đọc trong một method bất kỳ không đủ
AsyncPipe nhận emission{{ user$ | async }}Pipe tự gọi markForCheck() cho view chứa nó
Input mới cho component tạo độngcomponentRef.setInput('count', 10)Dùng API Angular thay vì gán property trên instance
Đánh dấu thủ côngcdr.markForCheck()Không cập nhật DOM đồng bộ ngay tại dòng gọi

Emission là một giá trị mà Observable phát ra. Observable là nguồn dữ liệu theo thời gian của RxJS, ví dụ chuỗi trạng thái tải hoặc chuỗi HTTP response.

Ngoài các cơ chế trên, detectChanges() có thể kiểm tra view trực tiếp. Đây là API điều khiển cục bộ, không phải cách mặc định để thiết kế data flow.

Input nhận giá trị mới

Với primitive như number, string hoặc boolean, đổi giá trị có thể tạo input mới:

this.quantity = 2; // Trước đó là 1.

Với object và array, điều cần quan tâm là reference — danh tính của object mà biến đang trỏ tới:

const oldUser = this.user;

this.user = { ...this.user, name: 'Bình' };

console.log(oldUser === this.user); // false: object mới.

Angular không quét sâu từng property của input để quyết định nó đã đổi. Trong implementation binding của Angular 18, phép so sánh dùng Object.is. Cả @Input() và input() đều không biến mutation sâu của object thành một input update tự động.

// Không tạo input object mới.
this.user.name = 'Bình';

// Tạo input object mới.
this.user = { ...this.user, name: 'Bình' };

Tuy nhiên, reference mới chưa đủ nếu cha chưa được kiểm tra. Khi field thường của cha đổi trong callback async, cha vẫn cần signal hoặc markForCheck() để binding [user]="user" được đánh giá.

Quy tắc thực hành: dùng cập nhật bất biến (immutable update) — tạo dữ liệu mới thay vì sửa object cũ — tại ranh giới state. Công bố thay đổi bằng notification rõ ràng, rồi truyền giá trị mới qua binding.

Event trong component và component con

Ví dụ bộ đếm đầu bài cho thấy field thường có thể cập nhật nhờ event. Notification đến từ (click), không đến từ phép gán count.

Một event trong component con cũng có thể khiến ancestor OnPush được kiểm tra:

direction: right

page: "PageComponent\nOnPush" {
  width: 130
  height: 58
}

editor: "EditorComponent\nOnPush" {
  width: 144
  height: 58
}

button: "SaveButton\n(click) ở đây" {
  width: 130
  height: 58
}

page -> editor -> button
PageComponentOnPushEditorComponentOnPushSaveButton(click) ở đây
PageComponentOnPushEditorComponentOnPushSaveButton(click) ở đây
Event trong cây OnPush

Đường liên quan từ SaveButton lên EditorComponent và PageComponent được đánh dấu. Các component OnPush ở nhánh sibling sạch vẫn có thể bị bỏ qua.

Điều này áp dụng cho listener Angular quản lý. Đừng xem callback của SDK bên thứ ba hoặc element.addEventListener() thủ công là tương đương hoàn toàn với (click) trong template.

Signal được đọc trong template thay đổi

Signal là một hộp giá trị có khả năng thông báo consumer — nơi sử dụng dữ liệu — đã đọc nó. Trong ví dụ sau, template đọc count(), nên Angular biết view này phụ thuộc count:

signal-counter.component.ts
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';

@Component({
  selector: 'app-signal-counter',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<p>{{ count() }}</p>`,
})
export class SignalCounterComponent {
  readonly count = signal(0);

  increase(): void {
    this.count.update((value) => value + 1);
  }
}

Nếu một callback gọi increase(), signal update là cơ chế thông báo. Component không cần nhận input mới hay chờ một click khác để hiện số mới.

Có hai điều kiện quan trọng:

  • Template phải có dependency vào signal, trực tiếp hoặc qua một computed mà nó đọc.
  • Giá trị ghi phải được signal xem là thay đổi theo equality function. Mặc định, signal cũng dùng Object.is.
// Sai: mutate object nhưng không ghi vào signal.
this.user().name = 'Bình';

// Đúng: ghi một object mới.
this.user.update((user) => ({ ...user, name: 'Bình' }));

Signal update có thể cho Angular đi qua ancestor để đến view consumer mà không phải chạy lại template của mọi ancestor sạch. Vì vậy, signal ở con đổi không có nghĩa toàn bộ cây cha phải được kiểm tra như khi có một event.

AsyncPipe nhận giá trị mới

AsyncPipe quản lý subscription của template. Khi nhận emission mới sau khi đã subscribe, pipe tự gọi markForCheck() cho view chứa nó. Nó cũng unsubscribe khi view bị hủy.

Bạn không cần tự gọi cdr.markForCheck() trong một subscription khác chỉ để template này nhận giá trị. AsyncPipe đã làm bước thông báo đó giúp bạn.

Ví dụ một service sở hữu state người dùng:

user-store.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';

export interface User {
  id: number;
  name: string;
}

@Injectable({ providedIn: 'root' })
export class UserStore {
  private readonly userSubject = new BehaviorSubject<User>({
    id: 1,
    name: 'An',
  });

  readonly user$ = this.userSubject.asObservable();

  rename(name: string): void {
    this.userSubject.next({ ...this.userSubject.value, name });
  }
}

BehaviorSubject là một Observable có giữ giá trị hiện tại và cấp giá trị đó cho subscriber mới. Component dùng AsyncPipe để đọc stream:

user-panel.component.ts
import { AsyncPipe } from '@angular/common';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { UserStore } from './user-store.service';

@Component({
  selector: 'app-user-panel',
  standalone: true,
  imports: [AsyncPipe],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @if (user$ | async; as user) {
      <p>{{ user.name }}</p>
    }
  `,
})
export class UserPanelComponent {
  readonly user$ = inject(UserStore).user$;
}

Khi một nơi khác gọi UserStore.rename('Bình'), UserPanelComponent vẫn cập nhật dù không có input và không xử lý event tại chính nó.

Nếu cha truyền một Observable cho con qua input, reference của Observable có thể giữ nguyên suốt đời component. Con vẫn cập nhật theo emission khi template của con dùng AsyncPipe. Reference stream không cần đổi để pipe thông báo.

Emission không chữa mọi mutation

AsyncPipe đánh dấu view chứa pipe, nhưng không tạo reference mới cho object được phát ra. Nếu view đó truyền cùng object đã mutate xuống một child OnPush khác, child vẫn có thể bị bỏ qua. Hãy phát dữ liệu bất biến ở ranh giới state.

Component tạo động nhận input qua setInput

Với component được tạo bằng ViewContainerRef.createComponent(), Angular trả về một ComponentRef. Đối tượng này cho phép quản lý component instance và view vừa tạo. Dùng setInput() để cập nhật input qua cơ chế Angular:

// Fragment tại nơi tạo component động.
// viewContainerRef là một instance ViewContainerRef đã được inject.
// UserCardComponent được định nghĩa trong ví dụ hoàn chỉnh ở dưới.
const componentRef = viewContainerRef.createComponent(UserCardComponent);
componentRef.setInput('user', { id: 1, name: 'Bình' });

Khi giá trị input thay đổi, setInput() đánh dấu component OnPush cần kiểm tra. API này cũng bảo đảm hook ngOnChanges được xử lý khi component được kiểm tra. Không cần gọi thêm markForCheck() chỉ để cập nhật input đó.

Ngược lại, componentRef.instance.user = nextUser chỉ là phép gán property JavaScript. Nó không tương đương setInput().

Đánh dấu bằng markForCheck

Khi buộc phải dùng field thường với callback async, ChangeDetectorRef.markForCheck() là cách báo rõ ràng rằng view cần kiểm tra.

Ví dụ sau có một timer RxJS. Subscription được dọn khi component bị hủy:

manual-tick.component.ts
import {
  ChangeDetectionStrategy,
  ChangeDetectorRef,
  Component,
  DestroyRef,
  inject,
  OnInit,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { interval } from 'rxjs';

@Component({
  selector: 'app-manual-tick',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<p>{{ ticks }}</p>`,
})
export class ManualTickComponent implements OnInit {
  private readonly cdr = inject(ChangeDetectorRef);
  private readonly destroyRef = inject(DestroyRef);

  ticks = 0;

  ngOnInit(): void {
    interval(1_000)
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe(() => {
        this.ticks += 1;
        this.cdr.markForCheck();
      });
  }
}

Nếu bỏ markForCheck(), việc timer chạy trong Zone.js cũng không bảo đảm template OnPush này được kiểm tra lại. Một tick toàn cục có thể xảy ra, nhưng view sạch vẫn bị bỏ qua.

Trong code mới, state signal hoặc AsyncPipe thường giúp tránh bước đánh dấu thủ công. Dùng markForCheck() khi cần một cầu nối có chủ đích với field thường hoặc API cũ.

OnPush ảnh hưởng cây component thế nào

Giả sử ứng dụng đã render lần đầu và không có view nào đang cần cập nhật:

direction: right

app: "AppComponent\nCheckAlways" {
  width: 130
  height: 58
}

header: "HeaderComponent\nOnPush" {
  width: 144
  height: 58
}

main: "MainComponent\nOnPush" {
  width: 130
  height: 58
}

card: "UserCardComponent\nOnPush" {
  width: 160
  height: 58
}

app -> header
app -> main -> card
AppComponentCheckAlwaysHeaderComponentOnPushMainComponentOnPushUserCardComponentOnPush
AppComponentCheckAlwaysHeaderComponentOnPushMainComponentOnPushUserCardComponentOnPush
Cây component với App CheckAlways

CheckAlways trong sơ đồ tương ứng với Default ở Angular 18 hoặc Eager ở v22. Bảng dưới giả định không có notification nào khác xảy ra cùng lúc:

Tình huốngKết quả chính
Click trong HeaderComponent, không đổi input của MainHeader được kiểm tra; Main và UserCard có thể bị bỏ qua
Click trong Main, input của UserCard giữ nguyênMain được kiểm tra; UserCard sạch vẫn có thể bị bỏ qua
Click trong UserCardUserCard và ancestor Main được đánh dấu để kiểm tra
App truyền input mới cho MainMain được kiểm tra; UserCard không tự động phải kiểm tra nếu input của nó giữ nguyên
Signal chỉ được template UserCard đọc thay đổiUserCard cần refresh; Angular đi qua đường ancestor cần thiết, không mặc định refresh template Main

Không nên suy luận “cha đã chạy thì mọi con đều chạy”. Mỗi ranh giới OnPush vẫn có điều kiện của riêng nó.

Chỉ một component OnPush và phần còn lại CheckAlways

Giả sử chỉ CounterComponent dùng OnPush. Các component còn lại đều dùng CheckAlways, không có view nào bị detach():

direction: right

app: "AppComponent\nCheckAlways" {
  width: 130
  height: 58
}

header: "HeaderComponent\nCheckAlways" {
  width: 144
  height: 58
}

main: "MainComponent\nCheckAlways" {
  width: 130
  height: 58
}

counter: "CounterComponent\nOnPush" {
  width: 152
  height: 58
}

footer: "FooterComponent\nCheckAlways" {
  width: 144
  height: 58
}

app -> header
app -> main -> counter
app -> footer
AppComponentCheckAlwaysHeaderComponentCheckAlwaysMainComponentCheckAlwaysCounterComponentOnPushFooterComponentCheckAlways
AppComponentCheckAlwaysHeaderComponentCheckAlwaysMainComponentCheckAlwaysCounterComponentOnPushFooterComponentCheckAlways
Counter OnPush trong cây CheckAlways

Trường hợp 1 — click trong Counter, app dùng Zone.js. Counter có field thường count = 0 và template:

<p>{{ count }}</p>
<button type="button" (click)="count = count + 1">+1</button>

Khi người dùng click, Angular đánh dấu Counter và đường ancestor liên quan cần kiểm tra. Event trong Angular zone của app dùng Zone.js dẫn tới một lượt change detection toàn cục. Trong cây này, tất cả các view CheckAlways đều được duyệt và kiểm tra; Counter cũng được kiểm tra vì có event.

Giả sử binding ở các component khác không đổi, kết quả là:

ComponentTemplate được kiểm tra?State có tự đổi theo Counter không?DOM có đổi không?
CounterCócount đổi từ 0 thành 1 do handlerText số đếm đổi thành 1
App và MainCóKhôngKhông, nếu binding giữ nguyên
Header và FooterCóKhôngKhông, nếu binding giữ nguyên

Vì vậy, nói “các component trong cây trên đều được kiểm tra” là đúng với lượt cập nhật này. Nhưng nói “state con đổi làm state cha đổi, rồi kéo state của mọi component đổi” là sai. State của Counter chỉ nằm trong Counter; không có input, output hay service nào chuyển giá trị mới sang các component khác trong ví dụ này.

direction: right

n0: "Click trong Counter" {
  width: 176
  height: 58
}

n1: "Angular chạy\nchange detection" {
  width: 152
  height: 58
}

n2: "Kiểm tra view\nđủ điều kiện" {
  width: 130
  height: 58
}

n3: "Chỉ binding đổi\nmới ghi DOM" {
  width: 144
  height: 58
}

n0 -> n1
n1 -> n2
n2 -> n3
Click trong CounterAngular chạychange detectionKiểm tra viewđủ điều kiệnChỉ binding đổimới ghi DOM
Click trong CounterAngular chạychange detectionKiểm tra viewđủ điều kiệnChỉ binding đổimới ghi DOM
Click không đồng nghĩa toàn bộ DOM được ghi lại

Cha được kiểm tra không có nghĩa state cha thay đổi

Angular có thể đọc lại template của App, Main, Header và Footer dù dữ liệu của chúng vẫn như cũ. Việc kiểm tra này không tạo lại component instance, không chạy lại constructor hay ngOnInit, và không mặc định viết lại DOM.

Chỉ bật OnPush cho Counter không tự khiến Header và Footer CheckAlways được bỏ qua trong lượt change detection toàn cục. OnPush là ranh giới kiểm tra của component đó, không phải công tắc giới hạn mọi update của app vào một component duy nhất.

Trường hợp 2 — signal chỉ được Counter đọc, app dùng zoneless. Giữ cấu trúc cây trên, nhưng đổi count thành signal(0) và template của Counter đọc {{ count() }}. Xét riêng một callback async gọi this.count.set(1) sau lần render đầu. Không có event Angular hoặc notification khác xảy ra cùng lúc.

Ở lượt cập nhật này, signal thông báo cho view consumer là Counter. Angular có thể dùng lượt kiểm tra hướng đích (Targeted) — tập trung vào view cần refresh:

  • Counter được kiểm tra và text số đếm đổi thành 1.
  • Angular đi qua đường App → Main để tới Counter, nhưng không cần refresh template của các ancestor sạch chỉ để đi tiếp.
  • Header và Footer sạch có thể được bỏ qua, dù strategy của chúng là CheckAlways.

CheckAlways không có nghĩa template phải được đánh giá sau mọi signal update. Phạm vi còn phụ thuộc loại lượt đồng bộ và notification nào đã xảy ra.

Không nên mở rộng kết quả của trường hợp signal này thành “zoneless luôn chỉ kiểm tra đúng một component”. Event Angular hoặc markForCheck() có thể khiến các ancestor được refresh và phạm vi kiểm tra rộng hơn.

Kết luận: với click trong app dùng Zone.js và cây ở trên, các component đều được kiểm tra. Với các nguồn cập nhật khác, hãy xác định notification và chế độ đồng bộ trước khi kết luận phạm vi kiểm tra. Trong mọi trường hợp, kiểm tra template không đồng nghĩa state hoặc DOM của mọi component đều thay đổi.

Component con dùng Default có vượt qua cha OnPush không

Đổi UserCard sang CheckAlways không làm nó tự chạy xuyên qua một cha OnPush sạch:

direction: right

main: "MainComponent\nOnPush, sạch" {
  width: 130
  height: 58
}

card: "UserCard\nCheckAlways" {
  width: 130
  height: 58
}

main -> card: "Cây view con"
MainComponentOnPush, sạchUserCardCheckAlways Cây view con
MainComponentOnPush, sạchUserCardCheckAlways Cây view con
Parent OnPush sạch có thể chặn việc duyệt subtree

Trong một lượt duyệt thông thường, nếu nhánh Main bị bỏ qua và không có descendant notification cần xử lý, Angular không đi tiếp vào UserCard. Chiến lược của con không tự vô hiệu hóa ranh giới của cha.

Nếu view con có notification hợp lệ, Angular có thể được chỉ dẫn đi vào nhánh đó. Đừng dùng Default ở con như cách chữa một luồng dữ liệu thiếu notification.

Ví dụ hoàn chỉnh về input và mutation

Ví dụ này dùng field thường để tách rõ vai trò của input và event. Không có signal hay AsyncPipe vô tình che mất hành vi cần quan sát.

Component con nhận object

user-card.component.ts
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';

export interface User {
  id: number;
  name: string;
}

@Component({
  selector: 'app-user-card',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <p data-testid="user-name">{{ user.name }}</p>
    <button type="button" (click)="probeClicks = probeClicks + 1">
      Click trong component con
    </button>
    <small>Số click ở con: {{ probeClicks }}</small>
  `,
})
export class UserCardComponent {
  @Input({ required: true }) user!: User;
  probeClicks = 0;
}

Input thuộc về cha. Nút ở con chỉ đổi state cục bộ probeClicks, không sửa object của cha.

Component cha cập nhật state

onpush-demo.component.ts
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { User, UserCardComponent } from './user-card.component';

@Component({
  selector: 'app-onpush-demo',
  standalone: true,
  imports: [UserCardComponent],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <button type="button" (click)="mutate()" data-testid="mutate">
      Mutate object cũ
    </button>
    <button type="button" (click)="replace()" data-testid="replace">
      Tạo object mới
    </button>
    <p data-testid="parent-name">Cha đang giữ: {{ user.name }}</p>
    <app-user-card [user]="user" />
  `,
})
export class OnPushDemoComponent {
  user: User = { id: 1, name: 'An' };

  mutate(): void {
    this.user.name = 'Bình';
  }

  replace(): void {
    this.user = { ...this.user, name: 'Chi' };
  }
}

Thử từng thao tác và giải thích kết quả

Render màn hình rồi thử theo thứ tự:

Thao tácText ở chaText ở conVì sao
Render lần đầuAnAnView được khởi tạo
Bấm Mutate object cũBìnhVẫn AnEvent đánh dấu cha; input reference của con không đổi
Bấm Click trong component conBìnhBìnhEvent ở con khiến template đọc lại object đã mutate
Bấm Tạo object mớiChiChiCha truyền reference input mới cho con

Đây là nguyên nhân bug “click một cái thì dữ liệu mới hiện”. Mutation đã đổi dữ liệu trong bộ nhớ, nhưng không tạo notification qua input. Một event đến sau vô tình làm template đọc lại dữ liệu đó.

Đừng kết luận mutation an toàn vì một lần click

Giao diện cập nhật nhờ một event khác không chứng minh mutation là reactive update. Hãy sửa điểm ghi state bằng immutable update, thay vì yêu cầu người dùng thao tác thêm hoặc rải detectChanges() để che lỗi.

Nếu thay @Input() bằng input.required<User>(), template đổi sang user().name. Cơ chế reference vẫn như cũ; Signal Input không tự phát hiện user.name bị sửa sâu ở cha.

Case thực tế: Save không cập nhật MessagesList

Case này dựa trên nhóm src/app/messages/ của project học 01-starting-project, dùng Angular 18 và Zone.js. Các đoạn code dưới đây rút gọn phần debug và style để tập trung vào luồng dữ liệu.

Cây component và service dùng chung

Người dùng nhập tin nhắn trong NewMessage rồi bấm Save. MessagesList cần hiển thị tin nhắn mới. Nhưng cả hai component đều dùng OnPush và là sibling — hai component cùng cha, không phải quan hệ cha con:

direction: right

app: "AppComponent\nCheckAlways" {
  width: 130
  height: 58
}

messages: "MessagesComponent\nOnPush" {
  width: 160
  height: 58
}

new: "NewMessageComponent\nOnPush, event Save" {
  width: 176
  height: 58
}

list: "MessagesListComponent\nOnPush, hiển thị dữ\nliệu" {
  width: 192
  height: 79
}

app -> messages
messages -> new
messages -> list
AppComponentCheckAlwaysMessagesComponentOnPushNewMessageComponentOnPush, event SaveMessagesListComponentOnPush, hiển thị dữliệu
AppComponentCheckAlwaysMessagesComponentOnPushNewMessageComponentOnPush, event SaveMessagesListComponentOnPush, hiển thị dữliệu
Hai nhánh OnPush chia sẻ dữ liệu messages

Phần ghép UI trong messages.component.html chỉ có:

<app-new-message />
<app-messages-list />

Không có input binding truyền danh sách từ cha xuống MessagesList. NewMessage và MessagesList cùng inject MessagesService có providedIn: 'root', nên trong cấu hình này chúng đọc cùng một instance service.

Mảng thường và getter trong MessagesList

Service ban đầu giữ dữ liệu bằng một mảng JavaScript thường:

messages.service.ts
import { Injectable } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class MessagesService {
  private messages: string[] = [];

  get allMessages(): string[] {
    return [...this.messages];
  }

  addMessage(message: string): void {
    this.messages = [...this.messages, message];
  }
}

MessagesList có các member đọc state như sau:

// Bên trong MessagesListComponent, đang dùng OnPush.
private messageService = inject(MessagesService);

get messages(): string[] {
  return this.messageService.allMessages;
}

Template dùng getter đó để render danh sách:

messages-list.component.html
<ul>
  @for (message of messages; track message) {
    <li>{{ message }}</li>
  }
</ul>

Trong NewMessage, form dùng (ngSubmit)="onSubmit()". Handler Save hiện tại là:

// Bên trong NewMessageComponent.
// mesageService là property inject MessagesService trong project.
onSubmit(): void {
  this.mesageService.addMessage(this.enteredText);
  this.enteredText = '';
}

Sau Save, service đã có mảng mới trong bộ nhớ. Việc xóa nội dung ô nhập cũng có thể xuất hiện trên UI của NewMessage, nhưng MessagesList vẫn hiển thị danh sách cũ.

Vì sao Save không đánh dấu sibling

Luồng xảy ra sau lần render đầu là:

  1. Angular xử lý (ngSubmit) trong NewMessage và đánh dấu view đó cùng đường ancestor cần kiểm tra.
  2. addMessage() tạo mảng mới trong service, nhưng đây vẫn là phép gán field thường.
  3. Messages được kiểm tra, nhưng template của nó không cấp input mới cho MessagesList.
  4. MessagesList không xử lý event Save, không đọc signal và không được đánh dấu bởi cơ chế khác.
  5. Angular bỏ qua template OnPush sạch của MessagesList. Getter messages và vòng @for không được đánh giá lại ở view đó.

Giả sử không có notification khác đồng thời đánh dấu MessagesList:

ViewĐược kiểm tra sau Save?Lý do
AppCóCheckAlways trong lượt change detection của app dùng Zone.js
MessagesCóAncestor trên đường event
NewMessageCóView xử lý event Save
MessagesListKhôngSibling OnPush sạch, không có input mới hoặc notification liên quan

Getter không phải một subscription — đăng ký nhận thông báo khi dữ liệu đổi. Angular chỉ gọi getter khi đánh giá expression cần nó. Getter có trả mảng mới mỗi lần gọi cũng không giúp được nếu template đang bị bỏ qua.

Mảng mới trong service không phải input mới của component

Service đã cập nhật bất biến bằng [...this.messages, message]; lỗi ở đây không phải mutate array tại chỗ. Mảng mới chỉ nằm trong field của service, không đi qua input binding tới MessagesList. Cùng inject một service cũng không tự đăng ký notification cho mọi component đọc field thường của nó.

Nói chính xác là Angular không kiểm tra lại template của MessagesList, chứ không phải service chưa lưu dữ liệu. Điều còn thiếu là notification hợp lệ cho view hiển thị danh sách.

Sửa bằng signal mà giữ nguyên getter

Có thể chỉ đổi phần state trong service sang signal. Getter vẫn trả về array như trước:

messages.service.ts
import { Injectable, signal } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class MessagesService {
  private readonly messages = signal<string[]>([]);

  get allMessages(): string[] {
    return [...this.messages()];
  }

  addMessage(message: string): void {
    this.messages.update((messages) => [...messages, message]);
  }
}

Giữ nguyên getter messages của MessagesList và template @for (message of messages; track message). Không đổi thành messages() trong cách sửa này, vì messages của component vẫn là một getter trả array, không phải signal.

Ở lần render đầu, template đọc getter và lần đọc đó đi tới signal trong service:

direction: right

n0: "Template MessagesList" {
  width: 192
  height: 58
}

n1: "getter messages" {
  width: 144
  height: 58
}

n2: "getter allMessages" {
  width: 168
  height: 58
}

n3: "Đọc signal messages()" {
  width: 192
  height: 58
}

n0 -> n1
n1 -> n2
n2 -> n3
Template MessagesListgetter messagesgetter allMessagesĐọc signal messages()
Template MessagesListgetter messagesgetter allMessagesĐọc signal messages()
Đọc signal gián tiếp vẫn tạo dependency template

Angular theo dõi signal được đọc trong lúc đánh giá template. Lần đọc đi qua các getter đồng bộ vẫn được ghi nhận, nên MessagesList trở thành consumer của signal trong service.

Sau Save, messages.update() công bố array mới. Signal thông báo cho MessagesList cần refresh, dù event Save nằm ở sibling NewMessage. Template của MessagesList được kiểm tra và @for nhận danh sách mới.

Điểm tạo notification là đọc và ghi signal, không phải việc getter tạo một bản sao array. Chỉ import signal mà vẫn khai báo private messages: string[] = [] thì chưa thay đổi cơ chế cập nhật.

Các cách khác và kiểm chứng

Signal không phải lựa chọn bắt buộc. Nếu giữ state không phải signal, vẫn cần một đường cập nhật view rõ ràng:

CáchĐiều gì khiến MessagesList được kiểm tra?
Service phát Observable, MessagesList dùng AsyncPipeEmission mới khiến pipe đánh dấu view chứa nó
Cha đọc dữ liệu và truyền input xuống ListKhi cha được kiểm tra, binding cấp array reference mới cho input của List
List nhận thông báo thay đổi và gọi markForCheck()Đánh dấu đúng view consumer; cần quản lý subscription nếu có

Gọi markForCheck() trên ChangeDetectorRef của NewMessage không đánh dấu sibling MessagesList. Đánh dấu thêm cha Messages cũng không tự làm mọi child OnPush sạch được kiểm tra. Nếu dùng cách thủ công, phải thông báo cho view hiển thị dữ liệu, không chỉ view ghi dữ liệu.

Để đối chiếu trong project Angular 18 này, có thể thử đổi riêng MessagesList sang ChangeDetectionStrategy.Default. Sau Save, cha Messages đã được đánh dấu bởi event, nên Angular đi tới List và đọc lại getter. Danh sách sẽ cập nhật trong luồng này dù service vẫn dùng mảng thường.

Cách thử đó chứng minh dữ liệu đã được lưu, nhưng không biến field thường thành reactive state. Nếu service đổi từ callback khác mà cha OnPush vẫn sạch, chỉ đổi List sang Default chưa bảo đảm UI được cập nhật.

Kiểm chứng tối thiểu với trạng thái mới sau khi tải lại trang:

  1. Bản mảng thường, List OnPush: nhập một tin nhắn khác rỗng rồi Save. Service giữ dữ liệu mới, ô nhập được xóa, nhưng danh sách vẫn cũ nếu List không có notification khác.
  2. Bản signal, List vẫn OnPush: tải lại trang, nhập tin nhắn rồi Save. Tin nhắn xuất hiện trong danh sách mà không cần thêm event ở List hay gọi detectChanges() thủ công.

Kết luận: trong case này, Save đã thông báo cho NewMessage và các ancestor, nhưng chưa thông báo cho MessagesList. Muốn giữ List OnPush, hãy nối nguồn state với view consumer bằng signal, AsyncPipe, input binding hoặc đánh dấu đúng view.

OnPush với danh sách và dữ liệu dẫn xuất

Mảng mới nhưng item cũ vẫn có thể gây UI cũ

Một array mới giúp component nhận array nhận ra input đổi. Nhưng mỗi row có thể là một component OnPush nhận item, không nhận toàn bộ array:

@for (user of users(); track user.id) {
  <app-user-card [user]="user" />
}

Đoạn dưới đổi array reference nhưng giữ lại item đã mutate:

// Sai: user có id = 1 vẫn là object cũ.
this.users.update((users) => {
  const user = users.find((item) => item.id === 1);
  if (user) user.name = 'Bình';
  return [...users];
});

Template danh sách được kiểm tra, nhưng row cho user id = 1 có thể tiếp tục bị bỏ qua vì input vẫn là object cũ.

// Đúng: array mới và item đã thay đổi cũng mới.
this.users.update((users) =>
  users.map((user) =>
    user.id === 1 ? { ...user, name: 'Bình' } : user,
  ),
);

Các item không đổi giữ nguyên reference. Angular có thể tái sử dụng row theo track user.id và bỏ qua các row sạch.

track giữ identity của row để tái sử dụng DOM. Nó không thay thế input notification, cũng không buộc child OnPush kiểm tra khi item reference giữ nguyên.

Không tạo input mới ở mọi lượt kiểm tra

OnPush mất nhiều lợi ích nếu một method của cha liên tục tạo object hoặc array mới dù dữ liệu nghiệp vụ không đổi:

// Fragment trong component cha.
getUser(): User {
  return { id: this.userId, name: this.userName };
}
<!-- Method trả object mới mỗi lần được gọi khi template cha được kiểm tra. -->
<app-user-card [user]="getUser()" />

Một getter trả về .map() mới mỗi lần cũng có thể gây tình trạng tương tự. Các reference mới khiến child liên tục nhận input mới.

Object literal trong template là trường hợp khác

Angular compiler có thể cache object/array literal viết trực tiếp trong template theo các giá trị binding của chúng. Vì vậy, không thể kết luận [user]="{ id: userId, name: userName }" luôn tạo reference mới ở mọi lượt kiểm tra. Vấn đề của ví dụ trên là method tự tạo object mới mỗi lần được gọi.

Với state signal, dùng computed để giữ kết quả dẫn xuất ổn định cho đến khi dependency đổi:

readonly userId = signal(1);
readonly userName = signal('An');

readonly user = computed(() => ({
  id: this.userId(),
  name: this.userName(),
}));
<app-user-card [user]="user()" />

Đoạn TypeScript trên là phần khai báo trong component; cần import signal và computed từ @angular/core.

computed cache kết quả đã tính cho đến khi dependency đổi. Một lượt kiểm tra không liên quan vì thế không tự tạo input object mới.

Chọn API của ChangeDetectorRef

Các cách cập nhật view mà không tự gọi markForCheck

Bạn không nhất thiết phải rải cdr.markForCheck() ở mọi nơi. Chọn cơ chế thông báo phù hợp với nguồn dữ liệu:

Tình huốngCách ưu tiênVí dụ
State cục bộ đổi từ timer hoặc callback asyncSignal được template đọccount.set(10) với {{ count() }}
Dữ liệu là ObservableAsyncPipe{{ count$ | async }}; pipe tự đánh dấu view khi nhận emission mới
Dữ liệu thuộc component chaInput qua binding, cập nhật bất biếnCha được kiểm tra và truyền object mới qua [user]="user"
State đổi đồng bộ do người dùng tương tácEvent Angular quản lý(click)="count = 10" trong template của component
Cấp dữ liệu cho component tạo độngComponentRef.setInput()componentRef.setInput('count', 10)

Với timer hai giây ở đầu bài, signal là lựa chọn đơn giản: callback gọi count.set(10), còn template đọc count(). Nếu dữ liệu vốn đã là Observable, dùng AsyncPipe để quản lý subscription và notification cùng một chỗ.

Một event chỉ đánh dấu view cho lượt kiểm tra liên quan đến event đó. Nếu handler đăng ký timer rồi timer gán field thường về sau, callback vẫn cần một cơ chế thông báo riêng. Không nên thêm một click vô nghĩa chỉ để UI tình cờ cập nhật.

Quy tắc chọn: để signal, AsyncPipe hoặc input API thực hiện thông báo; dùng markForCheck() tại ranh giới tích hợp khi vẫn phải giữ field thường.

markForCheck và detectChanges không giống nhau

APIHành viTrường hợp phù hợp
markForCheck()Đánh dấu view và đường ancestor liên quan cần kiểm tra; không ghi DOM ngayField thường đổi từ subscription hoặc callback tích hợp
detectChanges()Thực hiện kiểm tra cục bộ ngay cho view và các view con đủ điều kiệnLuồng kiểm soát local change detection có chủ đích
detach()Loại view khỏi các lượt kiểm tra tự độngUI dữ liệu dày cần tự quyết định nhịp refresh
reattach()Đưa view trở lại cây kiểm tra tự độngKết thúc chế độ kiểm soát thủ công
this.status = 'Đã tải xong';
this.cdr.markForCheck();
// DOM không được bảo đảm đã đổi ngay ở dòng này.

Ngược lại, detectChanges() kiểm tra view trực tiếp ngay tại điểm gọi, thay vì chỉ đánh dấu rồi chờ lượt kiểm tra được lên lịch:

this.count = 10;
this.cdr.detectChanges(); // Kiểm tra view này ngay.

Đây là cách chủ động kiểm tra, không phải một cơ chế đánh dấu khác. Tuy nhiên, đừng hiểu nó là “ép tất cả child OnPush sạch chạy bất kể điều kiện”. Các view con vẫn có quy tắc kiểm tra của chúng.

Lựa chọn mặc định: signal hoặc AsyncPipe cho state reactive; markForCheck() khi cần nối field thường vào Angular. Chỉ dùng detectChanges() khi thật sự cần kiểm tra cục bộ ngay và hiểu lifecycle tại điểm gọi.

View đã detach không được chữa bằng markForCheck

Một view detached không tham gia lượt kiểm tra tự động, kể cả khi được đánh dấu dirty:

this.cdr.detach();
this.status = 'Dữ liệu mới';
this.cdr.markForCheck(); // View vẫn chưa được kiểm tra tự động.

Nếu tự quản lý nhịp refresh, dùng detectChanges() để kiểm tra view tại thời điểm đã chọn. Nếu muốn trở về data flow thông thường, gọi reattach() và bảo đảm có notification phù hợp.

Không dùng detach() chỉ để “OnPush mạnh hơn”. Nó tạo trách nhiệm mới: lịch refresh, teardown, trạng thái hidden/visible và khả năng UI hiển thị dữ liệu cũ đều do bạn kiểm soát.

OnPush không phải zoneless

Zone.js tạo tick nhưng không vô hiệu hóa OnPush

Trong ứng dụng dùng Zone.js, timer hoặc HTTP callback có thể làm Angular chạy một lượt đồng bộ. Nhưng một component OnPush sạch vẫn có thể bị bỏ qua.

// Fragment trong component OnPush: notification còn thiếu.
this.http.get<User>('/api/user').subscribe((user) => {
  this.user = user;
});

HttpClient trả dữ liệu không đồng nghĩa mọi component đang giữ kết quả đều tự dirty. Subscription thủ công chỉ chạy callback mà bạn viết.

Có ba hướng rõ ràng:

  • Đưa Observable vào template qua AsyncPipe.
  • Ghi kết quả vào signal được template đọc, ví dụ this.user.set(user).
  • Nếu giữ field thường, gọi markForCheck() sau khi gán và quản lý vòng đời subscription.

takeUntilDestroyed() chỉ giải quyết cleanup của subscription. Nó không tự đánh dấu view. NgZone.run() cũng không thay thế việc đánh dấu một view OnPush sạch.

Zoneless cần notification rõ ràng

Zoneless bỏ sự phụ thuộc vào Zone.js để suy đoán lúc state có thể đổi. Angular dựa vào các notification như signal update, listener Angular, markForCheck() hoặc ComponentRef.setInput().

Một callback timer chỉ gán field thường không phải là notification:

setTimeout(() => {
  this.message = 'Xong'; // Chưa nói cho Angular view nào cần kiểm tra.
}, 1_000);

Dùng signal được template đọc:

readonly message = signal('Đang chờ');

// Trong method hoặc callback setup phù hợp:
setTimeout(() => {
  this.message.set('Xong');
}, 1_000);

Template tương ứng là {{ message() }}. Trong code thực tế, hủy timer hoặc subscription khi component bị hủy nếu công việc không còn cần thiết.

OnPush giúp kiểm tra xem component có data flow với notification rõ ràng hay không. Tuy vậy, OnPush không bắt buộc để dùng zoneless, và chỉ bật OnPush cũng không tự tắt Zone.js.

Những lỗi dễ gặp

Gán input trực tiếp qua ViewChild

Gán property trên instance không tương đương với một input binding Angular xử lý:

// Fragment: child là instance lấy được qua @ViewChild.
this.child.user = { id: 1, name: 'Bình' };

Cách này không tự thực hiện đầy đủ cơ chế input update, như đánh dấu OnPush và tạo ngOnChanges. Ưu tiên để cha sở hữu dữ liệu và truyền qua template:

<app-user-card [user]="user" />

Với component tạo động, dùng ComponentRef.setInput('user', nextUser) thay vì gán componentRef.instance.user trực tiếp. API này đi qua cơ chế input của Angular.

Nếu một public method của child là cần thiết, method đó nên tự quản lý state bằng signal hoặc đánh dấu view của chính child. Đừng chỉ đánh dấu cha rồi giả định con cũng sẽ được kiểm tra.

Đánh dấu view nhưng dữ liệu dẫn xuất vẫn cũ

OnPush, computed và pure pipe có các ranh giới cập nhật khác nhau. Pure pipe là pipe chỉ tính lại khi input của pipe thay đổi theo cơ chế so sánh của nó.

Giả sử label là computed từ signal user:

readonly user = signal({ name: 'An' });
readonly label = computed(() => `Xin chào ${this.user().name}`);

// Sai: cache của computed không được invalidated bởi mutation này.
this.user().name = 'Bình';
this.cdr.markForCheck();

Template có thể đọc lại user().name, nhưng label() vẫn có thể giữ Xin chào An vì signal chưa công bố giá trị mới. Một pure pipe nhận cùng object reference cũng có thể giữ kết quả cũ.

// Đúng: cập nhật nguồn reactive, thay vì ép view để che mutation.
this.user.update((user) => ({ ...user, name: 'Bình' }));

markForCheck() giải quyết view cần kiểm tra, không giải quyết mọi cache dữ liệu. Nếu state dẫn xuất bị cũ, sửa nguồn và dependency trước.

Dùng lifecycle hook như bộ đếm render tuyệt đối

Đếm ngDoCheck() hoặc đặt console.log() trong getter không phải phép đo chính xác “DOM render bao nhiêu lần”. Hook có scope riêng, getter có thể chạy nhiều lần và development mode thêm các bước kiểm tra.

Nếu mục tiêu là biết OnPush bỏ qua view nào, dùng profiler phù hợp. Nếu mục tiêu là biết UI chậm ở đâu, đọc thời gian JavaScript, layout và paint trong trace.

Kết luận: không dùng số log để khẳng định một component đã ghi lại DOM hoặc để chốt lợi ích performance.

Kiểm thử hành vi OnPush

Test input bằng ComponentRef

Trong component test, đặt input bằng API Angular thay vì gán property trực tiếp:

fixture.componentRef.setInput('user', { id: 1, name: 'Bình' });
fixture.detectChanges();

const name = fixture.nativeElement.querySelector('[data-testid="user-name"]');
expect(name.textContent?.trim()).toBe('Bình');

Đoạn trên giả định fixture của UserCardComponent đã được tạo. setInput() đánh dấu component phù hợp và bảo toàn semantics của input, bao gồm với Signal Input.

Test qua host để thấy input reference thật

Test trực tiếp child rồi gọi detectChanges() có thể không phản ánh việc child bị bỏ qua trong cây thật. Với bug mutation ở cha, dùng host có binding [user]="user".

Hai component của ví dụ hoàn chỉnh ở trên đã tạo đúng ranh giới này:

onpush-demo.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { OnPushDemoComponent } from './onpush-demo.component';

// Ví dụ dùng assertion Jasmine; có thể điều chỉnh cho runner của dự án.
describe('OnPush input boundary', () => {
  let fixture: ComponentFixture<OnPushDemoComponent>;

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

    fixture = TestBed.createComponent(OnPushDemoComponent);
    fixture.detectChanges();
  });

  function click(testId: string): void {
    const button = fixture.nativeElement.querySelector(
      `[data-testid="${testId}"]`,
    ) as HTMLButtonElement;
    button.click();
    fixture.detectChanges();
  }

  function childName(): string {
    return fixture.nativeElement
      .querySelector('app-user-card [data-testid="user-name"]')
      .textContent.trim();
  }

  it('keeps a clean child unchanged after an in-place mutation', () => {
    click('mutate');

    const parent = fixture.nativeElement.querySelector(
      '[data-testid="parent-name"]',
    );
    expect(parent.textContent).toContain('Bình');
    expect(childName()).toBe('An');
  });

  it('updates the child after receiving a new object reference', () => {
    click('replace');
    expect(childName()).toBe('Chi');
  });
});

Test mutation là test minh họa cơ chế, không phải hành vi nghiệp vụ nên giữ trong app thật. Trong ứng dụng thật, nút đổi tên cần dùng immutable update và test phải khẳng định cả cha lẫn con cập nhật.

Với app zoneless, thêm test để notification thật tự lên lịch: cấu hình TestBed tương ứng, kích hoạt event hoặc signal update, rồi await fixture.whenStable() trước assertion. Cách đó giúp phát hiện trường hợp chỉ chạy đúng khi test ép detectChanges().

Quy trình debug và áp dụng

Xác định view và nguồn state

Tìm component đang hiển thị sai. State là field thường, input, signal, Observable hay computed? Ai là owner có quyền ghi state đó?

Kiểm tra reference và notification

Với object/array, so sánh reference trước và sau update. Với callback async, kiểm tra có signal update, AsyncPipe hoặc markForCheck() không. Nếu cha đổi field, nhớ kiểm tra cha có được đánh dấu để đánh giá binding hay không.

Kiểm tra ranh giới con và cache

Một array mới có thể vẫn chứa item cũ. Một view đã được đánh dấu có thể vẫn đọc computed hoặc pure pipe cache cũ. Kiểm tra thêm view có bị detach() hay không.

Xác minh bằng tương tác và đo lại

Viết test cho đúng luồng đã lỗi: input từ cha, async response hoặc callback thư viện. Sau khi UI đúng, dùng profiler để xem các view không liên quan có được bỏ qua và interaction có nhanh hơn không.

Khi nào nên áp dụng OnPush

OnPush phù hợp cho component có contract rõ ràng: input bất biến, output thể hiện ý định người dùng, state signal hoặc Observable được template tiêu thụ đúng cách.

Với Angular 18, hãy chuyển từng subtree, bắt đầu từ component lá ít dependency. Kiểm tra mutation, subscription thủ công và callback SDK trước khi mở rộng. Với Angular v22, cơ chế này đã là mặc định; giữ data flow đúng vẫn quan trọng hơn việc có khai báo metadata hay không.

OnPush không làm một phép tính nặng chạy nhanh hơn khi view thật sự phải kiểm tra. Nó cũng không giảm số DOM node của bảng 10.000 hàng. Nếu nút thắt là tính toán, dùng thuật toán phù hợp hoặc cache dữ liệu dẫn xuất; nếu là DOM quá lớn, cân nhắc phân trang hoặc virtual scrolling.

Quyết định thực hành: dùng OnPush như ranh giới dữ liệu có notification rõ ràng, rồi đo để biết nó giảm được chi phí nào trong ứng dụng của bạn.

Checklist và tóm tắt

  • Tôi biết strategy mặc định của phiên bản Angular đang dùng.
  • Component nhận object/array input bằng reference mới khi dữ liệu thay đổi.
  • Mỗi item đã đổi trong list cũng có reference mới nếu truyền vào child OnPush.
  • Callback async cập nhật signal, được AsyncPipe tiêu thụ hoặc gọi markForCheck().
  • Binding của cha có cơ hội được đánh giá để truyền input mới xuống con.
  • Không mutate signal rồi dựa vào click khác để UI tình cờ cập nhật.
  • Không tạo input object/array mới vô điều kiện trong template hoặc getter.
  • Không dùng detectChanges() để che lỗi dependency hoặc mutation.
  • Test kiểm tra hành vi qua binding, event và async flow giống ứng dụng thật.

Tóm lại, OnPush bỏ qua view không có lý do cần kiểm tra; nó không giới hạn cập nhật chỉ ở input. Input mới, event, signal dependency, AsyncPipe, ComponentRef.setInput() và markForCheck() đều là những cơ chế quan trọng.

Có change detection chạy không bảo đảm mọi view được kiểm tra. Một view sạch vẫn có thể giữ DOM cũ dù field trong bộ nhớ đã đổi. Ưu tiên các cơ chế thông báo tự động, thay vì gọi detectChanges() để che một điểm ghi state thiếu notification.

Mô hình dễ nhớ là: state mới → notification đúng → view được kiểm tra → DOM chỉ đổi ở binding khác biệt.

Tài liệu tham khảo và đi tiếp

Các điểm về phiên bản và API được đối chiếu với tài liệu Angular chính thức:

On this page

Mục lụcOnPush là gìBa khái niệm cần tách biệtMô hình từ state đến DOMVì sao có change detection mà UI vẫn không đổiCấu hình và khác biệt phiên bảnDefault và OnPush khác nhau ở đâuĐọc tài liệu theo đúng phiên bảnKhi nào component OnPush được kiểm traInput nhận giá trị mớiEvent trong component và component conSignal được đọc trong template thay đổiAsyncPipe nhận giá trị mớiComponent tạo động nhận input qua setInputĐánh dấu bằng markForCheckOnPush ảnh hưởng cây component thế nàoChỉ một component OnPush và phần còn lại CheckAlwaysComponent con dùng Default có vượt qua cha OnPush khôngVí dụ hoàn chỉnh về input và mutationComponent con nhận objectComponent cha cập nhật stateThử từng thao tác và giải thích kết quảCase thực tế: Save không cập nhật MessagesListCây component và service dùng chungMảng thường và getter trong MessagesListVì sao Save không đánh dấu siblingSửa bằng signal mà giữ nguyên getterCác cách khác và kiểm chứngOnPush với danh sách và dữ liệu dẫn xuấtMảng mới nhưng item cũ vẫn có thể gây UI cũKhông tạo input mới ở mọi lượt kiểm traChọn API của ChangeDetectorRefCác cách cập nhật view mà không tự gọi markForCheckmarkForCheck và detectChanges không giống nhauView đã detach không được chữa bằng markForCheckOnPush không phải zonelessZone.js tạo tick nhưng không vô hiệu hóa OnPushZoneless cần notification rõ ràngNhững lỗi dễ gặpGán input trực tiếp qua ViewChildĐánh dấu view nhưng dữ liệu dẫn xuất vẫn cũDùng lifecycle hook như bộ đếm render tuyệt đốiKiểm thử hành vi OnPushTest input bằng ComponentRefTest qua host để thấy input reference thậtQuy trình debug và áp dụngXác định view và nguồn stateKiểm tra reference và notificationKiểm tra ranh giới con và cacheXác minh bằng tương tác và đo lạiKhi nào nên áp dụng OnPushChecklist và tóm tắtTài liệu tham khảo và đi tiếp