Angular Signals Nâng Cao: Làm Chủ Biến Đổi Dữ Liệu Với Computed Và Xử Lý Tác Vụ Phụ Với Effect
Bạn đã biết khai báo một signal() đơn giản để lưu trạng thái. Nhưng liệu bạn đã thực sự khai thác hết sức mạnh của hệ thống Signals trong Angular?
Nếu bạn đang xây dựng một ứng dụng thực tế, chắc chắn bạn sẽ gặp những tình huống như:
- Tính toán tổng tiền giỏ hàng từ danh sách sản phẩm.
- Lưu trạng thái người dùng vào
localStoragemỗi khi có thay đổi. - Ghi log hoặc gọi API khi một giá trị nào đó thay đổi.
WritableSignal (khai báo bằng signal()) chỉ giải quyết được phần ngọn của vấn đề. Để xây dựng một luồng reactivity hoàn chỉnh, sạch sẽ và hiệu năng cao, bạn cần làm chủ bộ đôi quyền lực: computed và effect.
Nhắc lại kiến trúc: Khi Writable Signal đơn thuần là chưa đủ
Hãy tưởng tượng bạn đang xây dựng một component giỏ hàng. Bạn có hai WritableSignal: unitPrice (đơn giá) và quantity (số lượng). Nhiệm vụ của bạn là hiển thị tổng tiền (thành tiền) ra giao diện.
Với cách tiếp cận thủ công, bạn có thể làm như sau:
import { Component, signal } from '@angular/core';
@Component({
selector: 'app-cart',
template: `
<p>Đơn giá: {{ unitPrice() }}</p>
<p>Số lượng: {{ quantity() }}</p>
<p>Tổng tiền: {{ total() }}</p>
<button (click)="increaseQuantity()">Tăng số lượng</button>
`,
standalone: true,
})
export class CartComponent {
unitPrice = signal
<number>(100000);
quantity = signal
<number>(1);
// ❌ Cách làm thủ công: phải tự tính toán và cập nhật mỗi khi dependency thay đổi
total = signal
<number>(this.unitPrice() * this.quantity());
increaseQuantity() {
this.quantity.update(q => q + 1);
// ⚠️ Bắt buộc phải nhớ cập nhật total mỗi lần!
this.total.set(this.unitPrice() * this.quantity());
}
}
Vấn đề của cách làm này:
- Dễ sai sót: Bạn phải nhớ cập nhật
totalở mọi nơi màunitPricehoặcquantitythay đổi. - Không tái sử dụng: Logic tính toán bị rải rác khắp nơi.
- Vi phạm nguyên tắc Single Source of Truth:
totallà dữ liệu phái sinh, không phải dữ liệu gốc.
Đây chính là lúc computed xuất hiện để giải cứu bạn.

1. Toàn tập về computed(): Tính toán lười biếng và Cơ chế ghi nhớ siêu tốc
computed() là một hàm dùng để tạo ra một Signal chỉ đọc (read-only), giá trị của nó được phái sinh (derive) từ các Signal khác.
Cú pháp cơ bản
import { Component, signal, computed, Signal } from '@angular/core';
@Component({
selector: 'app-cart',
template: `...`,
standalone: true,
})
export class CartComponent {
unitPrice = signal
<number>(100000);
quantity = signal
<number>(1);
// ✅ total là một computed signal, tự động cập nhật khi unitPrice hoặc quantity thay đổi
total: Signal
<number> = computed(() => this.unitPrice() * this.quantity());
}
Điểm mạnh của computed: Lazy Evaluation & Memoization

Theo tài liệu chính thức của Angular, computed có hai đặc tính quan trọng:
- Lazy Evaluation (Tính toán lười biếng): Hàm tính toán bên trong
computedchỉ chạy khi bạn đọc giá trị của computed signal đó lần đầu tiên. Nếu không ai đọc, nó sẽ không bao giờ chạy. - Memoization (Ghi nhớ kết quả): Sau khi tính toán, giá trị được cache lại. Nếu bạn đọc computed signal đó nhiều lần mà các dependency không thay đổi, Angular sẽ trả về giá trị đã cache mà không chạy lại hàm tính toán.
Điều này mang lại hiệu năng vượt trội, đặc biệt khi bạn có các phép tính phức tạp như lọc, sắp xếp mảng lớn.
Ví dụ thực tế: Bộ lọc tìm kiếm sản phẩm
Giả sử bạn có một danh sách sản phẩm và một ô tìm kiếm. Bạn muốn hiển thị danh sách sản phẩm đã được lọc theo từ khóa tìm kiếm.
import { Component, signal, computed, Signal } from '@angular/core';
interface Product {
id: number;
name: string;
price: number;
}
@Component({
selector: 'app-product-list',
template: `
<input [value]="searchTerm()" (input)="searchTerm.set($any($event.target).value)" placeholder="Tìm sản phẩm..." />
<ul>
<li *ngFor="let product of filteredProducts()">
{{ product.name }} - {{ product.price | currency }}
</li>
</ul>
`,
standalone: true,
})
export class ProductListComponent {
// State gốc
products = signal<Product[]>([
{ id: 1, name: 'Laptop', price: 15000000 },
{ id: 2, name: 'Chuột không dây', price: 500000 },
{ id: 3, name: 'Bàn phím cơ', price: 1200000 },
{ id: 4, name: 'Màn hình 4K', price: 8000000 },
]);
searchTerm = signal
<string>('');
// ✅ computed: filteredProducts tự động cập nhật mỗi khi products hoặc searchTerm thay đổi
filteredProducts: Signal<Product[]> = computed(() => {
const term = this.searchTerm().toLowerCase().trim();
if (!term) {
return this.products();
}
// Lọc mảng - chỉ chạy lại khi thực sự cần
return this.products().filter(product =>
product.name.toLowerCase().includes(term)
);
});
}
Giải thích:
filteredProductslà mộtcomputedsignal.- Nó phụ thuộc vào
productsvàsearchTerm. - Mỗi khi người dùng nhập vào ô tìm kiếm,
searchTermthay đổi →filteredProductstự động tính toán lại. - Angular chỉ chạy lại hàm lọc khi
productshoặcsearchTermthực sự thay đổi. Nhờ cơ chế memoization, nếu bạn gõ cùng một từ khóa nhiều lần, computed sẽ trả về kết quả đã cache mà không chạy lại.
⚠️ Quy tắc vàng: computed phải là Pure Function
Hàm bên trong computed() tuyệt đối không được:
- Thay đổi giá trị của bất kỳ Signal nào (gọi
set()hoặcupdate()). - Gọi API, đọc/ghi
localStorage, hay thực hiện bất kỳ Side Effect nào.
Lý do: computed có thể được chạy lại bất kỳ lúc nào bởi Angular. Nếu bạn thực hiện side effect bên trong, bạn sẽ gặp lỗi khó hiểu và hiệu năng giảm sút.
2. Làm chủ effect(): Trạm điều phối các tác vụ Side-Effect
Nếu computed là nơi biến đổi dữ liệu (Data Transformation), thì effect() là nơi xử lý tác vụ phụ (Side Effect) – những việc cần làm khi dữ liệu thay đổi nhưng không làm thay đổi chính dữ liệu đó.
effect() là gì?
effect() đăng ký một hàm sẽ được thực thi mỗi khi các Signal mà nó đọc thay đổi. Đây là công cụ lý tưởng để:
- Ghi log.
- Đồng bộ dữ liệu vào
localStorage. - Gọi API (nhưng cần cẩn thận).
- Tương tác với DOM không thuộc Angular.
- Điều khiển các thư viện bên thứ ba (vẽ biểu đồ Canvas, khởi tạo animation, v.v.).
⚠️ Lưu ý quan trọng: effect() đã được ổn định (stable) từ Angular v20. Trong các phiên bản trước (v16–v19), API này ở trạng thái developer preview.
Ví dụ: Đồng bộ giỏ hàng vào localStorage
import { Component, signal, computed, effect, inject } from '@angular/core';
@Component({
selector: 'app-cart',
template: `...`,
standalone: true,
})
export class CartComponent {
private readonly STORAGE_KEY = 'cart_data';
items = signal<CartItem[]>([]);
// computed: tổng tiền
total = computed(() => this.items().reduce((sum, item) => sum + item.price * item.quantity, 0));
constructor() {
// ✅ effect: tự động lưu vào localStorage mỗi khi items thay đổi
effect(() => {
const currentItems = this.items();
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(currentItems));
console.log('🛒 Giỏ hàng đã được lưu:', currentItems.length, 'sản phẩm');
});
}
}
Giải thích:
effectđọcthis.items()– Angular tự động theo dõi dependency này.- Mỗi khi
itemsthay đổi, effect sẽ chạy lại và lưu dữ liệu vàolocalStorage. - Effect luôn chạy ít nhất một lần khi được khởi tạo.
Cạm bẫy chết người: Lỗi vòng lặp cập nhật dữ liệu vô tận bên trong effect
Đây là lỗi phổ biến nhất và nguy hiểm nhất khi làm việc với Signals.
Mặc dù Angular đã loại bỏ flag allowSignalWrites từ v19 và cho phép ghi Signal trong effect mặc định, nhưng việc này vẫn tiềm ẩn nguy cơ tạo ra vòng lặp vô tận nếu không cẩn thận.
// ⚠️ CẢNH BÁO: Có thể gây vòng lặp vô tận!
effect(() => {
const value = this.signalA();
this.signalB.set(value + 1); // Việc này được cho phép từ v19
});
Tại sao nguy hiểm?
effectđọcsignalA→ thay đổisignalB.signalBthay đổi → kích hoạteffectchạy lại.effectlại đọcsignalAvà thay đổisignalB… Vòng lặp vô tận!
Angular thông minh đến mức tự động phát hiện và ném lỗi ngay lập tức để bảo vệ ứng dụng của bạn.
Giải pháp:
- Ưu tiên dùng
computed: Nếu bạn đang cố gắng tạo ra một giá trị phái sinh, hãy dùngcomputedthay vìeffect. - Angular 19+: Dùng
linkedSignal()– một công cụ mới được giới thiệu để đồng bộ state mà không cần dùngeffect.
✨ Angular 19+: linkedSignal() – Lựa chọn thay thế tốt hơn cho effect
linkedSignal() là một tính năng mới (đã ổn định từ Angular v20) giúp tạo ra một Signal có thể ghi (writable) nhưng tự động đồng bộ với một Signal nguồn.

Ví dụ: Thay thế effect bằng linkedSignal
Cách cũ dùng effect (dễ gây lỗi):
effect(() => {
this.discount.set(this.total() > 1000000 ? this.total() * 0.1 : 0);
});
Cách mới dùng linkedSignal (sạch sẽ hơn):
import { linkedSignal } from '@angular/core';
discount = linkedSignal({
source: this.total,
computation: (total) => total > 1000000 ? total * 0.1 : 0
});
Lợi ích:
- ✅ Không lo vòng lặp vô tận
- ✅ Code rõ ràng, dễ hiểu hơn
- ✅ Hiệu năng tốt hơn
Vũ khí bí mật: Hàm dọn rác onCleanup bên trong cấu trúc Effect

Một trong những tính năng bị nhiều bài viết bỏ qua là onCleanup – tham số callback cho phép bạn đăng ký hàm dọn dẹp tài nguyên mỗi khi effect bị hủy hoặc sắp chạy lại.
Tại sao cần onCleanup?
Khi bạn khởi tạo một tài nguyên bên trong effect (như setInterval, lắng nghe sự kiện DOM, hoặc subscribe Observable), bạn cần dọn dẹp tài nguyên đó trước khi effect chạy lại hoặc component bị hủy để tránh rò rỉ bộ nhớ.
Ví dụ: Theo dõi sự kiện resize cửa sổ với onCleanup
import { Component, signal, effect, OnDestroy } from '@angular/core';
@Component({
selector: 'app-window-size',
template: `
<p>Chiều rộng: {{ windowWidth() }}px</p>
<p>Chiều cao: {{ windowHeight() }}px</p>
`,
standalone: true,
})
export class WindowSizeComponent {
windowWidth = signal
<number>(window.innerWidth);
windowHeight = signal
<number>(window.innerHeight);
constructor() {
// ✅ effect với onCleanup - quản lý tài nguyên đỉnh cao
effect((onCleanup) => {
// 1. Định nghĩa handler
const handleResize = () => {
this.windowWidth.set(window.innerWidth);
this.windowHeight.set(window.innerHeight);
console.log('📐 Kích thước cửa sổ đã thay đổi');
};
// 2. Đăng ký lắng nghe sự kiện
window.addEventListener('resize', handleResize);
// 3. Đăng ký cleanup: tự động gỡ bỏ listener khi effect bị hủy
onCleanup(() => {
window.removeEventListener('resize', handleResize);
console.log('🧹 Đã gỡ bỏ listener resize');
});
});
}
}
Giải thích chi tiết:
effect((onCleanup) => { ... }):onCleanuplà một function được truyền vào effect.- Bên trong effect, chúng ta đăng ký lắng nghe sự kiện
resize. - Ngay sau đó, chúng ta gọi
onCleanup(() => { ... })để đăng ký hàm dọn dẹp. - Khi component bị hủy hoặc effect sắp chạy lại (do dependency thay đổi), Angular sẽ tự động gọi hàm cleanup này.
Quy trình hoạt động của onCleanup
- Effect chạy lần đầu → đăng ký listener → đăng ký cleanup.
- Dependency thay đổi → effect sắp chạy lại → Angular gọi hàm cleanup (gỡ bỏ listener cũ).
- Effect chạy lại với giá trị mới → đăng ký listener mới → đăng ký cleanup mới.
- Component bị hủy → Angular gọi cleanup lần cuối.
💡 Mẹo: onCleanup cực kỳ hữu ích khi bạn gọi API trong effect. Mỗi lần dependency thay đổi, bạn có thể hủy request cũ trước khi gọi request mới, tránh tình trạng “dữ liệu cũ chồng dữ liệu mới”.
Mô hình Đồ Thị Phụ Thuộc Ngầm (Glitch-free Dependency Graph)
Một trong những điểm khác biệt lớn nhất giữa Signals và RxJS là cơ chế đảm bảo không có “Glitch” – hiện tượng hiển thị sai lệch giá trị trung gian khi có nhiều dependency thay đổi cùng lúc.
Vấn đề với RxJS
Trong RxJS, khi bạn kết hợp nhiều Observable với nhau (ví dụ: combineLatest), nếu nhiều Observable cùng emit giá trị mới, bạn có thể nhận được các giá trị trung gian không nhất quán.
Cách Signals giải quyết
Angular Signals sử dụng thuật toán push/pull hai pha:
- Push: Khi một Signal thay đổi, Angular đánh dấu tất cả các computed/effect phụ thuộc là “không hợp lệ” (invalid) nhưng không tính toán lại ngay.
- Pull: Chỉ khi bạn đọc giá trị của computed signal, Angular mới thực hiện tính toán lại.
Cơ chế này đảm bảo rằng bạn luôn nhận được giá trị cuối cùng, nhất quán sau khi tất cả các Signal đã được cập nhật xong, không bao giờ nhìn thấy giá trị trung gian.
🔍 Kiểm chứng: Các tài liệu chính thức của Angular khẳng định Signals đảm bảo “glitch-free” nhờ cơ chế xử lý dependency graph nội bộ. Đây là một lợi thế lớn so với việc tự tay kết hợp RxJS.
Lỗi thường gặp và cách khắc phục
1. Gây vòng lặp vô tận trong effect()
Lỗi:
effect(() => {
const total = this.total();
this.discount.set(total > 1000000 ? total * 0.1 : 0); // ⚠️ Có thể gây loop
});
Nguyên nhân: discount thay đổi → có thể kích hoạt effect chạy lại (nếu effect đọc discount).
Cách khắc phục:
- Cách 1 (Khuyến nghị): Chuyển logic thành
computed:discount = computed(() => { const total = this.total(); return total > 1000000 ? total * 0.1 : 0; }); - Cách 2 (Angular 19+): Dùng
linkedSignal():discount = linkedSignal({ source: this.total, computation: (total) => total > 1000000 ? total * 0.1 : 0 });
2. Quên dọn dẹp tài nguyên trong effect
Lỗi: Khởi tạo setInterval hoặc đăng ký sự kiện mà không dùng onCleanup → rò rỉ bộ nhớ khi component bị hủy.
Cách khắc phục: Luôn sử dụng onCleanup để dọn dẹp tài nguyên.
3. Gọi API trong computed
Lỗi:
// ❌ SAI: computed không được dùng để gọi API
userProfile = computed(() => {
this.http.get('/api/user').subscribe(); // ⚠️ Side effect trong computed
return this.userId();
});
Cách khắc phục: Chuyển logic gọi API sang effect hoặc service riêng.
Best Practices
| Quy tắc | Giải thích |
|---|---|
Luôn giữ computed là Pure Function |
Không thay đổi biến ngoài, không gọi API, không side effect. |
Chỉ khai báo effect() trong Injection Context |
Khai báo trong constructor của Component/Service để Angular tự động hủy effect khi component bị unmount. |
Ưu tiên computed hoặc linkedSignal hơn effect cho việc biến đổi dữ liệu |
Nếu bạn đang copy dữ liệu từ signal này sang signal khác trong effect, hãy xem xét dùng computed hoặc linkedSignal. |
Luôn dùng onCleanup cho tài nguyên |
Mỗi khi tạo setInterval, addEventListener, hoặc subscribe trong effect, hãy đăng ký cleanup ngay lập tức. |
Tránh ghi Signal trong effect nếu không cần thiết |
Mặc dù được cho phép từ Angular 19, nhưng việc này vẫn tiềm ẩn rủi ro vòng lặp. Hãy cân nhắc dùng computed hoặc linkedSignal. |
FAQ
1. Hàm computed() của Angular Signals có giống hoàn toàn với useMemo() của React hay không?
Không hoàn toàn. Cả hai đều có cơ chế memoization (ghi nhớ kết quả), nhưng có sự khác biệt quan trọng:
useMemocủa React: Bạn phải khai báo dependency array một cách tường minh. Nếu bạn quên thêm dependency, memo sẽ không cập nhật.computedcủa Angular: Tự động theo dõi các Signal được đọc bên trong. Không cần khai báo dependency array – Angular tự biết khi nào cần tính toán lại.
Điều này làm cho computed vừa mạnh mẽ hơn, vừa ít lỗi hơn so với useMemo.
2. Có thể biến đổi một RxJS Observable thành một Signal và ngược lại bằng cách nào?
Hoàn toàn có thể! Angular cung cấp package @angular/core/rxjs-interop với hai hàm chính:
toSignal(observable): Chuyển Observable → Signal.
import { inject, Injectable } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
@Injectable()
export class UserService {
private http = inject(HttpClient);
// Observable → Signal
user = toSignal(this.http.get
<User>('/api/user'), { initialValue: null });
}
toObservable(signal): Chuyển Signal → Observable.
import { toObservable } from '@angular/core/rxjs-interop';
const count = signal(0);
const count$ = toObservable(count);
// count$ là Observable, có thể dùng với RxJS operators
⚠️ Lưu ý: toSignal yêu cầu bạn cung cấp initialValue hoặc đảm bảo Observable emit giá trị ngay lập tức.
Kết luận
computed và effect là hai trụ cột không thể thiếu khi bạn muốn xây dựng một ứng dụng Angular với Signals chuyên nghiệp:
computed |
effect |
|
|---|---|---|
| Vai trò | Biến đổi dữ liệu (Data Transformation) | Xử lý tác vụ phụ (Side Effect) |
| Loại Signal | Read-only (chỉ đọc) | Không trả về Signal |
| Memoization | ✅ Có – cache kết quả | ❌ Không |
| Side Effect | ❌ Cấm tuyệt đối | ✅ Được phép |
| Sử dụng cho | Tính toán giá trị phái sinh, lọc dữ liệu | Ghi log, localStorage, gọi API, tương tác DOM |
Nguyên tắc vàng: Hãy để dòng chảy dữ liệu của bạn đi theo một chiều: State gốc (signal) → Biến đổi (computed hoặc linkedSignal) → Tác vụ phụ (effect). Đừng cố gắng đi ngược dòng.
📌 Cập nhật Angular 19+: Flag allowSignalWrites đã bị loại bỏ. Việc ghi Signal trong effect được cho phép mặc định. Tuy nhiên, hãy luôn cân nhắc dùng computed hoặc linkedSignal thay vì effect cho việc biến đổi dữ liệu để tránh vòng lặp vô tận.