Angular Signals và Computed năm 2026: Reactivity Chi Tiết và Câu Hỏi Phỏng Vấn

Tìm hiểu Angular Signals và computed signals để có reactivity chi tiết trong Angular 20+. Hướng dẫn toàn diện bao gồm signal primitives, linkedSignal, tương tác RxJS và các câu hỏi phỏng vấn kỹ thuật.

Angular Signals và Computed - Reactivity Chi Tiết năm 2026

Angular Signals đại diện cho sự thay đổi quan trọng nhất trong mô hình reactivity của Angular kể từ khi framework này ra đời. Được giới thiệu trong Angular 17 và hiện là phương pháp mặc định trong Angular 20+, signals cung cấp reactivity chi tiết loại bỏ các chu kỳ change detection không cần thiết và tích hợp liền mạch với các ứng dụng zoneless.

Điểm Quan Trọng cho Phỏng Vấn

Signals là các primitive reactive đồng bộ và không có glitch. Computed signal chỉ tính toán lại khi dependencies thay đổi và chỉ một lần mỗi chu kỳ thay đổi, bất kể signal được đọc bao nhiêu lần.

Hiểu Signal Primitives trong Angular 20

Hệ thống signal của Angular bao gồm ba primitive cốt lõi: signal(), computed(), và effect(). Mỗi primitive phục vụ một mục đích riêng biệt trong reactive graph.

Writable signal lưu trữ giá trị có thể được cập nhật bằng .set() hoặc .update(). Đọc signal trả về giá trị hiện tại, và bất kỳ computed signal hoặc effect nào phụ thuộc vào nó sẽ được thông báo về các thay đổi.

counter.component.tstypescript
import { Component, signal, computed } from '@angular/core';

@Component({
  selector: 'app-counter',
  standalone: true,
  template: `
    <p>Count: {{ count() }}</p>
    <p>Double: {{ doubleCount() }}</p>
    <button (click)="increment()">+1</button>
  `
})
export class CounterComponent {
  // Writable signal với giá trị khởi tạo
  count = signal(0);
  
  // Computed signal được dẫn xuất từ count
  // Chỉ tính toán lại khi count thay đổi
  doubleCount = computed(() => this.count() * 2);
  
  increment(): void {
    // .update() nhận giá trị hiện tại làm tham số
    this.count.update(c => c + 1);
  }
}

Sự khác biệt chính so với RxJS BehaviorSubject: signals là đồng bộ và không có glitch. Khi count thay đổi, doubleCount tính toán lại đúng một lần, ngay cả khi được đọc nhiều lần trong cùng một block đồng bộ.

Computed Signals: Đánh Giá Lazy và Memoization

Computed signals cache kết quả cho đến khi dependency thay đổi. Memoization này là tự động, không giống RxJS nơi shareReplay hoặc distinctUntilChanged phải được thêm thủ công.

user-stats.component.tstypescript
import { Component, signal, computed } from '@angular/core';

interface User {
  id: number;
  name: string;
  purchases: number[];
}

@Component({
  selector: 'app-user-stats',
  standalone: true,
  template: `
    <div>
      <p>User: {{ user().name }}</p>
      <p>Total spent: {{ totalSpent() | currency }}</p>
      <p>Average purchase: {{ averagePurchase() | currency }}</p>
    </div>
  `
})
export class UserStatsComponent {
  user = signal<User>({
    id: 1,
    name: 'Alice',
    purchases: [99.99, 149.50, 29.99]
  });
  
  // Computed chỉ theo dõi các properties được đọc
  totalSpent = computed(() => {
    const purchases = this.user().purchases;
    return purchases.reduce((sum, p) => sum + p, 0);
  });
  
  // Phụ thuộc vào computed signal khác
  averagePurchase = computed(() => {
    const total = this.totalSpent();
    const count = this.user().purchases.length;
    return count > 0 ? total / count : 0;
  });
}

Computed signal đánh giá theo kiểu lazy: hàm computation chỉ chạy khi signal được đọc lần đầu, không phải khi khai báo. Các lần đọc tiếp theo trả về giá trị được cache cho đến khi dependency làm mất hiệu lực.

Effect: Side Effects trong Reactive Graph

Effects thực thi code để phản hồi các thay đổi signal. Chúng chạy bất đồng bộ trong chu kỳ change detection, sau khi tất cả các cập nhật đồng bộ hoàn thành.

theme-sync.component.tstypescript
import { Component, signal, effect, inject } from '@angular/core';
import { DOCUMENT } from '@angular/common';

@Component({
  selector: 'app-theme-sync',
  standalone: true,
  template: `
    <button (click)="toggleTheme()">Toggle Theme</button>
  `
})
export class ThemeSyncComponent {
  private document = inject(DOCUMENT);
  
  isDarkMode = signal(false);
  
  constructor() {
    // Effect đồng bộ state signal với thuộc tính DOM
    effect(() => {
      const theme = this.isDarkMode() ? 'dark' : 'light';
      this.document.documentElement.setAttribute('data-theme', theme);
    });
    
    // Effect cho persistence localStorage
    effect(() => {
      localStorage.setItem('theme', this.isDarkMode() ? 'dark' : 'light');
    });
  }
  
  toggleTheme(): void {
    this.isDarkMode.update(dark => !dark);
  }
}

Effects theo dõi dependencies một cách động. Nếu một nhánh điều kiện không được thực thi, các signals chỉ được đọc trong nhánh đó không được theo dõi cho đến khi điều kiện thay đổi.

Anti-Pattern: Effect cho Dẫn Xuất State

Sử dụng effect để sao chép dữ liệu từ một signal sang signal khác cho thấy vấn đề thiết kế. Nên sử dụng computed cho derived state hoặc linkedSignal khi giá trị dẫn xuất cần có thể ghi được.

linkedSignal: Writable Derived State

Được giới thiệu trong Angular 19 và ổn định trong Angular 20, linkedSignal tạo writable signal được reset khi nguồn thay đổi. Nó giải quyết các tình huống khi computed là read-only nhưng giá trị dẫn xuất cần sửa đổi cục bộ.

pagination.component.tstypescript
import { Component, signal, linkedSignal, computed } from '@angular/core';

@Component({
  selector: 'app-pagination',
  standalone: true,
  template: `
    <select (change)="pageSize.set(+$any($event.target).value)">
      <option [value]="10">10 per page</option>
      <option [value]="25">25 per page</option>
      <option [value]="50">50 per page</option>
    </select>
    <p>Page {{ currentPage() }} of {{ totalPages() }}</p>
    <button (click)="prevPage()" [disabled]="currentPage() === 1">Prev</button>
    <button (click)="nextPage()" [disabled]="currentPage() === totalPages()">Next</button>
  `
})
export class PaginationComponent {
  totalItems = signal(243);
  pageSize = signal(10);
  
  totalPages = computed(() => 
    Math.ceil(this.totalItems() / this.pageSize())
  );
  
  // linkedSignal reset về 1 khi pageSize hoặc totalItems thay đổi
  // nhưng cho phép điều hướng thủ công qua prevPage/nextPage
  currentPage = linkedSignal(() => 1);
  
  prevPage(): void {
    this.currentPage.update(p => Math.max(1, p - 1));
  }
  
  nextPage(): void {
    this.currentPage.update(p => Math.min(this.totalPages(), p + 1));
  }
}

Không có linkedSignal, pattern này yêu cầu effect ghi vào signal khác (anti-pattern) hoặc điều phối RxJS phức tạp.

Sẵn sàng chinh phục phỏng vấn Angular?

Luyện tập với mô phỏng tương tác, flashcards và bài kiểm tra kỹ thuật.

Signal-Based Component APIs: input, model, viewChild

Angular 20 đưa signal-based component APIs lên trạng thái ổn định. Chúng thay thế decorators bằng các alternative reactive tích hợp vào signal graph.

user-card.component.tstypescript
import { 
  Component, 
  input, 
  model, 
  output, 
  computed,
  viewChild,
  ElementRef
} from '@angular/core';

@Component({
  selector: 'app-user-card',
  standalone: true,
  template: `
    <div #card class="card" [class.selected]="selected()">
      <h3>{{ fullName() }}</h3>
      <button (click)="toggle()">{{ selected() ? 'Deselect' : 'Select' }}</button>
    </div>
  `
})
export class UserCardComponent {
  // Signal input thay thế @Input()
  firstName = input.required<string>();
  lastName = input<string>('');
  
  // model() tạo signal có thể bind hai chiều
  // Parent sử dụng [(selected)]="parentSignal"
  selected = model(false);
  
  // Output emitter cho events
  cardClicked = output<void>();
  
  // viewChild trả về Signal<ElementRef | undefined>
  cardElement = viewChild<ElementRef>('card');
  
  // Computed dẫn xuất từ signal inputs
  fullName = computed(() => 
    `${this.firstName()} ${this.lastName()}`.trim()
  );
  
  toggle(): void {
    this.selected.update(s => !s);
    this.cardClicked.emit();
  }
}

Signal inputs cung cấp type safety tốt hơn: input.required() trả về InputSignal<T> trong khi input() trả về InputSignal<T | undefined> trừ khi có default được cung cấp.

Signals vs RxJS: Hiệu Năng Dưới Tải Cao

Benchmarks trong Angular 21+ cho thấy signals giảm overhead change detection trong các tình huống UI-heavy. Dưới tải cao với cập nhật thường xuyên, signals cho thấy memory profiles sạch hơn và sử dụng CPU thấp hơn so với bindings dựa trên RxJS.

Sự phân tách concerns rõ ràng:

Use CasePhương Pháp Được Khuyến Nghị
State component cục bộsignal()
Giá trị UI dẫn xuấtcomputed()
Đồng bộ với APIs bên ngoàieffect()
HTTP requestsRxJS Observable + toSignal()
WebSocket streamsRxJS với operators
Input người dùng có debounceRxJS debounceTime, sau đó toSignal()
Điều phối async phức tạpRxJS pipelines

RxJS vẫn là công cụ cho async streams, cancellation, và backpressure. Signals xử lý state đồng bộ.

Tương Tác RxJS: toSignal và toObservable

Angular cung cấp các utilities trong @angular/core/rxjs-interop để kết nối signals và observables.

search.component.tstypescript
import { Component, signal, inject } from '@angular/core';
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
import { debounceTime, switchMap, distinctUntilChanged } from 'rxjs';

interface SearchResult {
  id: number;
  title: string;
}

@Component({
  selector: 'app-search',
  standalone: true,
  template: `
    <input 
      [value]="query()" 
      (input)="query.set($any($event.target).value)"
      placeholder="Search..."
    />
    @if (results(); as items) {
      <ul>
        @for (item of items; track item.id) {
          <li>{{ item.title }}</li>
        }
      </ul>
    }
  `
})
export class SearchComponent {
  private http = inject(HttpClient);
  
  query = signal('');
  
  // Chuyển đổi signal thành observable cho operators RxJS
  private query$ = toObservable(this.query);
  
  // RxJS pipeline với debounce, sau đó quay lại signal
  results = toSignal(
    this.query$.pipe(
      debounceTime(300),
      distinctUntilChanged(),
      switchMap(q => q.length > 2 
        ? this.http.get<SearchResult[]>(`/api/search?q=${q}`)
        : []
      )
    ),
    { initialValue: [] as SearchResult[] }
  );
}

toSignal subscribe khi được tạo và unsubscribe khi component bị destroy. Tùy chọn initialValue loại bỏ type undefined khỏi signal.

Câu Hỏi Phỏng Vấn: Angular Signals

Các cuộc phỏng vấn kỹ thuật năm 2026 thường xuyên kiểm tra sự hiểu biết về signal. Dưới đây là các patterns phân biệt ứng viên senior.

Q: Điều gì xảy ra nếu computed signal throw error?

Error lan truyền đến bất kỳ effect hoặc computed nào đọc nó. Angular không cache errors, computation thử lại ở lần đọc tiếp theo. Code production nên xử lý errors bên trong hàm computed.

Q: Có thể ghi vào signal bên trong computed không?

Không. Cố gắng gọi .set() hoặc .update() bên trong computed sẽ throw error. Các hàm computed phải pure. Sử dụng linkedSignal nếu cần giá trị dẫn xuất cũng có thể ghi được.

Q: Effects xử lý đọc signal trong điều kiện như thế nào?

typescript
// Ví dụ theo dõi dependency động
const showDetails = signal(false);
const userDetails = signal({ name: 'Alice', email: 'alice@example.com' });

effect(() => {
  if (showDetails()) {
    // userDetails chỉ được theo dõi khi showDetails là true
    console.log('Details:', userDetails().email);
  }
});

Effects theo dõi dependencies dựa trên lần thực thi gần nhất. Nếu showDetails là false, các thay đổi với userDetails không kích hoạt effect.

Q: Equality của signal hoạt động như thế nào?

Mặc định, signals sử dụng Object.is cho equality. Với objects, điều này có nghĩa là reference equality. Custom equality có thể được cung cấp:

typescript
const user = signal(
  { id: 1, name: 'Alice' },
  { equal: (a, b) => a.id === b.id }
);
Insight Phỏng Vấn

Ứng viên đề cập rằng computed signals được memoize và đánh giá lazy cho thấy sự hiểu biết vượt ra ngoài việc sử dụng API cơ bản. Thảo luận khi nào sử dụng linkedSignal vs computed cho thấy sự quen thuộc với các patterns Angular 19+.

Change Detection: Signals và Zoneless Angular

Angular 22 mặc định sử dụng zoneless change detection cho các dự án mới. Signals là trung tâm của điều này: chúng thông báo cho Angular chính xác khi state thay đổi, loại bỏ nhu cầu Zone.js patch các async APIs.

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

@Component({
  selector: 'app-zoneless-counter',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <p>Count: {{ count() }}</p>
    <button (click)="increment()">+1</button>
  `
})
export class ZonelessCounterComponent {
  count = signal(0);
  
  increment(): void {
    // Cập nhật signal kích hoạt change detection
    // Không cần Zone.js
    this.count.update(c => c + 1);
  }
}

Với signals, Angular theo dõi chính xác components nào phụ thuộc vào state nào. Cập nhật signal lên lịch change detection chỉ cho các components bị ảnh hưởng, không phải toàn bộ tree.

Resource API: Async Data dưới dạng Signals

Resource API của Angular 20 (resource()rxResource()) tải async data vào signals một cách declarative, thay thế các patterns subscription thủ công.

user-profile.component.tstypescript
import { Component, signal, computed } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import { inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';

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

@Component({
  selector: 'app-user-profile',
  standalone: true,
  template: `
    @if (userResource.isLoading()) {
      <p>Loading...</p>
    }
    @if (userResource.error()) {
      <p>Error: {{ userResource.error() }}</p>
    }
    @if (userResource.value(); as user) {
      <h2>{{ user.name }}</h2>
      <p>{{ user.email }}</p>
    }
  `
})
export class UserProfileComponent {
  private http = inject(HttpClient);
  
  userId = signal(1);
  
  // Resource async declarative
  // Refetch khi userId thay đổi
  userResource = rxResource({
    request: () => this.userId(),
    loader: ({ request: id }) => 
      this.http.get<User>(`/api/users/${id}`)
  });
  
  loadUser(id: number): void {
    this.userId.set(id);
  }
}

Resource tự động theo dõi loading state, errors, và giá trị đã resolve. Nó refetch khi request signal thay đổi.

Best Practices cho Kiến Trúc Signal

Cấu trúc signals trong các ứng dụng lớn hơn đòi hỏi kỷ luật. Các patterns này scale từ components đến services.

Colocate các signals liên quan: Nhóm các signals thay đổi cùng nhau. Component form giữ các field signals của nó cùng nhau, không phân tán qua các services.

Nâng shared state lên services: Khi nhiều components cần cùng state, di chuyển signal đến injectable service:

cart.service.tstypescript
import { Injectable, signal, computed } from '@angular/core';

interface CartItem {
  id: number;
  name: string;
  price: number;
  quantity: number;
}

@Injectable({ providedIn: 'root' })
export class CartService {
  private items = signal<CartItem[]>([]);
  
  // Expose read-only signal cho consumers
  readonly cartItems = this.items.asReadonly();
  
  readonly totalPrice = computed(() =>
    this.items().reduce((sum, item) => sum + item.price * item.quantity, 0)
  );
  
  readonly itemCount = computed(() =>
    this.items().reduce((sum, item) => sum + item.quantity, 0)
  );
  
  addItem(item: Omit<CartItem, 'quantity'>): void {
    this.items.update(items => {
      const existing = items.find(i => i.id === item.id);
      if (existing) {
        return items.map(i => 
          i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
        );
      }
      return [...items, { ...item, quantity: 1 }];
    });
  }
}

Ưu tiên computed hơn effect: Nếu giá trị có thể dẫn xuất được, sử dụng computed. Effects dành cho side effects rời khỏi reactive graph: thao tác DOM, localStorage, analytics, network requests.

Di Chuyển từ RxJS sang Signals

Các codebase Angular legacy có thể di chuyển dần dần. Các utilities interop RxJS cho phép signals và observables cùng tồn tại.

typescript
// Trước: Dựa trên RxJS
@Component({ ... })
export class LegacyComponent {
  private userSubject = new BehaviorSubject<User | null>(null);
  user$ = this.userSubject.asObservable();
  
  userName$ = this.user$.pipe(
    map(u => u?.name ?? 'Guest')
  );
}

// Sau: Dựa trên Signal
@Component({ ... })
export class ModernComponent {
  user = signal<User | null>(null);
  
  userName = computed(() => this.user()?.name ?? 'Guest');
}

Với các operations async, giữ RxJS cho pipeline và chuyển đổi ở boundary:

typescript
// Phương pháp hybrid
private search$ = toObservable(this.searchQuery).pipe(
  debounceTime(300),
  switchMap(q => this.api.search(q))
);

results = toSignal(this.search$, { initialValue: [] });

Bắt đầu luyện tập!

Kiểm tra kiến thức với mô phỏng phỏng vấn và bài kiểm tra kỹ thuật.

Chuẩn Bị Câu Hỏi Phỏng Vấn Angular Signals

  • Signals là các primitives đồng bộ; RxJS xử lý async streams
  • Computed signals là lazy và được memoize, tính toán lại chỉ khi dependencies thay đổi
  • Effects chạy bất đồng bộ trong change detection, không phải ngay lập tức
  • linkedSignal giải quyết vấn đề writable-derived-state mà không cần effects
  • Signal inputs (input(), model()) thay thế decorators bằng các alternative reactive
  • toSignaltoObservable kết nối signals và RxJS để di chuyển dần dần
  • Zoneless Angular dựa vào signals cho change detection chi tiết
  • Resource API (rxResource) quản lý async state một cách declarative
  • Hướng dẫn chính thức Angular signals vẫn là tài liệu tham khảo có thẩm quyền cho chi tiết API và best practices
Thử thách hôm nay

Bạn có tìm ra lỗi trong Angular không?

Một đoạn mã thật, một lỗi ẩn, mỗi ngày một lượt. Không cần tài khoản để thử.

Anthony Fillion-Maillet

Viết bởi

Anthony Fillion-Maillet

Người sáng lập SharpSkill

Lập trình viên fullstack hơn 10 năm. Anh điều hành SharpSkill và chịu trách nhiệm về mọi nội dung đăng tại đây.

Cập nhật ngày 17 tháng 9, 2026

Thẻ

#angular
#signals
#computed
#reactivity
#interview

Chia sẻ

Bài viết liên quan