15 KiB
15 KiB
Angular Change Detection Optimization
Comprehensive guide to Angular change detection, OnPush strategy, Signals, and Zone.js optimization.
Table of Contents
- How Change Detection Works
- Default vs OnPush
- OnPush Strategy Patterns
- Angular Signals
- Zone.js Optimization
- Manual Change Detection
- Common Pitfalls
- Performance Monitoring
How Change Detection Works
The Basics
Angular uses Zone.js to detect async operations and trigger change detection:
// Any of these trigger change detection:
setTimeout(() => {}); // ✓ Triggers CD
setInterval(() => {}, 1000); // ✓ Triggers CD
fetch('/api/data'); // ✓ Triggers CD
button.addEventListener('click'); // ✓ Triggers CD
Promise.resolve(); // ✓ Triggers CD
The Component Tree
App Component
/ \
/ \
Header Main
/ \
/ \
Sidebar Content
/ \
/ \
List Detail
Default Strategy: When CD runs, Angular checks every component in the tree.
Change Detection Cycle
- Event occurs (click, HTTP response, timer)
- Zone.js intercepts the async operation
- Angular runs change detection from root
- Each component checks if bindings changed
- DOM updates if changes detected
Default vs OnPush
Default Strategy
@Component({
selector: 'app-user-list',
template: `
<div>{{ currentTime }}</div>
<button (click)="refresh()">Refresh</button>
`
})
export class UserListComponent {
currentTime = new Date().toLocaleTimeString();
refresh() {
this.currentTime = new Date().toLocaleTimeString();
}
}
Behavior:
- Checks on every CD cycle
- Even if data didn't change
- Performance cost: High (scales with components)
OnPush Strategy
@Component({
selector: 'app-user-list',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div>{{ currentTime }}</div>
<button (click)="refresh()">Refresh</button>
`
})
export class UserListComponent {
currentTime = new Date().toLocaleTimeString();
refresh() {
this.currentTime = new Date().toLocaleTimeString();
}
}
Behavior:
- Only checks when:
- @Input() reference changes
- Event handler in component template
- Observable emits with async pipe
- Manual detection triggered
- Performance cost: Low (90% reduction)
Performance Comparison
// Scenario: 100 components, 10 CD cycles/second
// Default Strategy:
// 100 components × 10 cycles × 60 seconds = 60,000 checks/minute
// OnPush Strategy:
// ~5 components × 10 cycles × 60 seconds = 3,000 checks/minute
// ⚡ 95% reduction!
OnPush Strategy Patterns
Pattern 1: Immutable Data
@Component({
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProductListComponent {
@Input() products: Product[] = [];
// ❌ BAD: Mutates array (OnPush won't detect)
addProductBad(product: Product) {
this.products.push(product);
}
// ✅ GOOD: New array reference
addProductGood(product: Product) {
this.products = [...this.products, product];
}
// ✅ GOOD: Immutable update
updateProduct(id: string, updates: Partial<Product>) {
this.products = this.products.map(p =>
p.id === id ? { ...p, ...updates } : p
);
}
}
Pattern 2: Async Pipe
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- ✅ Async pipe triggers OnPush automatically -->
<div *ngFor="let user of users$ | async">
{{ user.name }}
</div>
`
})
export class UserListComponent {
users$ = this.userService.getUsers();
constructor(private userService: UserService) {}
}
Pattern 3: Event Handlers
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<button (click)="increment()">{{ count }}</button>
`
})
export class CounterComponent {
count = 0;
// ✅ Event handlers in template trigger OnPush
increment() {
this.count++;
}
}
Pattern 4: Signals (Angular 16+)
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div>{{ count() }}</div>
<button (click)="increment()">+</button>
`
})
export class CounterComponent {
count = signal(0);
increment() {
this.count.update(c => c + 1);
}
}
Angular Signals
Signal Basics
// Create signal
const count = signal(0);
// Read signal
console.log(count()); // 0
// Update signal
count.set(5);
count.update(c => c + 1);
// Computed signal (derived state)
const doubled = computed(() => count() * 2);
Signals in Components
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<h2>Cart ({{ itemCount() }} items)</h2>
<div>Total: {{ total() | currency }}</div>
<div *ngFor="let item of items()">
{{ item.name }} - {{ item.price | currency }}
<button (click)="removeItem(item.id)">Remove</button>
</div>
`
})
export class CartComponent {
items = signal<CartItem[]>([]);
itemCount = computed(() => this.items().length);
total = computed(() =>
this.items().reduce((sum, item) => sum + item.price, 0)
);
addItem(item: CartItem) {
this.items.update(items => [...items, item]);
}
removeItem(id: string) {
this.items.update(items => items.filter(i => i.id !== id));
}
}
Signal Effects
export class UserComponent {
userId = signal<string | null>(null);
user = signal<User | null>(null);
constructor() {
// Effect runs when dependencies change
effect(() => {
const id = this.userId();
if (id) {
this.userService.getUser(id).subscribe(
user => this.user.set(user)
);
}
});
}
}
Signals vs Observables
// Observable approach
export class ProductsComponent {
private searchTerm$ = new BehaviorSubject('');
products$ = this.searchTerm$.pipe(
debounceTime(300),
switchMap(term => this.productService.search(term))
);
search(term: string) {
this.searchTerm$.next(term);
}
}
// Signal approach
export class ProductsComponent {
searchTerm = signal('');
products = computed(() => {
// Note: computed is synchronous
// For async, combine with effect
return this.productService.search(this.searchTerm());
});
search(term: string) {
this.searchTerm.set(term);
}
}
// Hybrid approach (best for now)
export class ProductsComponent {
searchTerm = signal('');
products$ = toObservable(this.searchTerm).pipe(
debounceTime(300),
switchMap(term => this.productService.search(term))
);
}
Zone.js Optimization
Run Outside Zone
export class ChartComponent {
constructor(private ngZone: NgZone) {}
startAnimation() {
// Run outside Angular's zone (no CD triggered)
this.ngZone.runOutsideAngular(() => {
const animate = () => {
// Heavy animation logic
this.updateChart();
requestAnimationFrame(animate);
};
requestAnimationFrame(animate);
});
}
// Manually trigger CD when needed
updateData(data: any) {
this.ngZone.run(() => {
this.chartData = data;
});
}
}
Polling Outside Zone
export class LiveDataComponent implements OnInit, OnDestroy {
data: any;
private interval: any;
constructor(private ngZone: NgZone) {}
ngOnInit() {
// Poll every second without triggering CD
this.ngZone.runOutsideAngular(() => {
this.interval = setInterval(() => {
this.fetchData().then(data => {
// Only trigger CD when data arrives
this.ngZone.run(() => {
this.data = data;
});
});
}, 1000);
});
}
ngOnDestroy() {
clearInterval(this.interval);
}
}
Zone-less Angular (Experimental)
// main.ts
bootstrapApplication(AppComponent, {
providers: [
provideExperimentalZonelessChangeDetection()
]
});
// Component must use OnPush + Signals
@Component({
changeDetection: ChangeDetectionStrategy.OnPush
})
export class MyComponent {
count = signal(0); // Signals work without Zone.js
}
Manual Change Detection
ChangeDetectorRef
@Component({
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ManualComponent {
data: any;
constructor(private cdr: ChangeDetectorRef) {}
// Method 1: detectChanges() - Check this component
updateData(data: any) {
this.data = data;
this.cdr.detectChanges();
}
// Method 2: markForCheck() - Mark for next CD cycle
scheduleUpdate(data: any) {
this.data = data;
this.cdr.markForCheck();
}
// Method 3: detach() - Stop automatic CD
ngOnInit() {
this.cdr.detach();
// Manual control
setInterval(() => {
this.data = new Date();
this.cdr.detectChanges();
}, 1000);
}
// Method 4: reattach() - Resume automatic CD
enableAutoDetection() {
this.cdr.reattach();
}
}
When to Use Manual Detection
// ✅ Use Case 1: Third-party lib updates
export class MapComponent {
constructor(private cdr: ChangeDetectorRef) {}
initMap() {
this.mapLibrary.on('update', (data) => {
this.mapData = data;
this.cdr.markForCheck(); // Tell Angular to check
});
}
}
// ✅ Use Case 2: WebSocket updates
export class LiveFeedComponent {
constructor(
private cdr: ChangeDetectorRef,
private ws: WebSocketService
) {}
ngOnInit() {
this.ws.messages$.subscribe(msg => {
this.messages.push(msg);
this.cdr.markForCheck();
});
}
}
// ✅ Use Case 3: Performance-critical updates
export class GameComponent {
constructor(private cdr: ChangeDetectorRef) {}
ngOnInit() {
this.cdr.detach(); // Stop auto CD
// Game loop
const loop = () => {
this.updateGame();
// Only trigger CD when rendering
if (this.shouldRender) {
this.cdr.detectChanges();
}
requestAnimationFrame(loop);
};
requestAnimationFrame(loop);
}
}
Common Pitfalls
Pitfall 1: Mutating @Input
// ❌ BAD: Parent change won't trigger OnPush
@Component({
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ChildComponent {
@Input() user: User;
}
// Parent mutates object
this.user.name = 'Updated'; // OnPush child won't detect
// ✅ GOOD: Create new reference
this.user = { ...this.user, name: 'Updated' };
Pitfall 2: Nested Objects
// ❌ BAD: Nested mutation
@Input() config = { theme: { color: 'blue' } };
this.config.theme.color = 'red'; // Won't trigger OnPush
// ✅ GOOD: Deep immutable update
this.config = {
...this.config,
theme: { ...this.config.theme, color: 'red' }
};
Pitfall 3: Array Modifications
// ❌ BAD: Mutating array
@Input() items: string[] = [];
this.items.push('new'); // Won't trigger
this.items.splice(0, 1); // Won't trigger
this.items[0] = 'updated'; // Won't trigger
// ✅ GOOD: New array
this.items = [...this.items, 'new'];
this.items = this.items.filter((_, i) => i !== 0);
this.items = this.items.map((item, i) => i === 0 ? 'updated' : item);
Pitfall 4: Async without async pipe
// ❌ BAD: Manual subscription in OnPush
@Component({
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BadComponent {
users: User[] = [];
ngOnInit() {
this.userService.getUsers().subscribe(users => {
this.users = users; // Won't trigger OnPush!
});
}
}
// ✅ GOOD: Use async pipe
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<div *ngFor="let user of users$ | async">...</div>`
})
export class GoodComponent {
users$ = this.userService.getUsers();
}
// ✅ GOOD: Or use markForCheck
@Component({
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AlsoGoodComponent {
users: User[] = [];
constructor(private cdr: ChangeDetectorRef) {}
ngOnInit() {
this.userService.getUsers().subscribe(users => {
this.users = users;
this.cdr.markForCheck();
});
}
}
Performance Monitoring
Chrome DevTools Profiler
// 1. Open Chrome DevTools
// 2. Performance tab
// 3. Click Record
// 4. Interact with app
// 5. Stop recording
// Look for:
// - Long tasks (>50ms)
// - Excessive change detection
// - Layout thrashing
Angular DevTools
// 1. Install Angular DevTools extension
// 2. Open DevTools > Angular tab
// 3. Profiler section
// 4. Record profile
// 5. Analyze change detection cycles
// Shows:
// - Change detection timing
// - Component tree
// - Change detection strategy per component
Performance Marks
export class ProfiledComponent {
loadData() {
performance.mark('data-load-start');
this.service.getData().subscribe(data => {
this.data = data;
performance.mark('data-load-end');
performance.measure(
'Data Load',
'data-load-start',
'data-load-end'
);
// View in Performance tab
const measure = performance.getEntriesByName('Data Load')[0];
console.log(`Data load took ${measure.duration}ms`);
});
}
}
Change Detection Counter
// Directive to count CD cycles
@Directive({
selector: '[cdCounter]',
standalone: true
})
export class CdCounterDirective implements DoCheck {
private count = 0;
ngDoCheck() {
this.count++;
console.log(`CD cycle #${this.count}`);
}
}
// Usage
<app-my-component cdCounter></app-my-component>
Best Practices Summary
✅ DO:
- Use
ChangeDetectionStrategy.OnPushby default - Use
asyncpipe for observables - Use Angular Signals for reactive state
- Create new references for objects/arrays
- Use
trackByfor lists - Run heavy operations outside Zone
- Use
markForCheck()for third-party integrations
❌ DON'T:
- Mutate
@Input()properties - Manually subscribe in OnPush without
markForCheck() - Use Default strategy unless necessary
- Perform heavy computations in getters
- Forget to unsubscribe from observables
- Mix imperative and reactive patterns
Quick Reference
Change Detection Triggers (OnPush)
✓ @Input() reference changes
✓ Event from template
✓ async pipe emission
✓ cdr.detectChanges()
✓ cdr.markForCheck()
✓ Signal updates (in templates)
✗ Object mutation
✗ Array.push/splice/pop
✗ Nested property changes
✗ Manual subscription
✗ setTimeout/setInterval (without events)
Strategy Comparison
| Feature | Default | OnPush |
|---|---|---|
| Checks on every CD | ✓ | ✗ |
| Checks on @Input change | ✓ | ✓ (reference) |
| Checks on events | ✓ | ✓ |
| Checks on async pipe | ✓ | ✓ |
| Performance | Low | High |
| Complexity | Low | Medium |
Master change detection for blazing-fast Angular apps! 🚀