Change Detection Deep Dive
Overview
Angular's change detection system automatically updates the DOM when application state changes. Understanding it is crucial for performance optimization.
Change Detection Strategies
Default Strategy
Checks entire component tree on every browser event.
import { Component } from "@angular/core";
@Component({
selector: "app-default",
// Default strategy (implicit)
template: `
<div>{{ counter }}</div>
<button (click)="increment()">+1</button>
`,
})
export class DefaultComponent {
counter = 0;
increment() {
this.counter++;
// Entire component tree checks for changes
}
}
When to use:
- Simple applications
- Prototypes
- Components with frequent updates
OnPush Strategy
Checks component only when:
- Input references change
- Events originate from component
- Manual trigger (
markForCheck()) - Async pipe emits
import { Component, ChangeDetectionStrategy, Input } from "@angular/core";
@Component({
selector: "app-user-card",
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="card">
<h3>{{ user.name }}</h3>
<p>{{ user.email }}</p>
<button (click)="onClick()">Click</button>
</div>
`,
})
export class UserCardComponent {
@Input() user!: { name: string; email: string };
onClick() {
console.log("Clicked");
// Component will check for changes
}
}
Immutable pattern required:
// ❌ Bad - OnPush won't detect
this.user.name = "New Name";
// ✅ Good - Creates new reference
this.user = { ...this.user, name: "New Name" };
Manual Change Detection
import { Component, ChangeDetectorRef, ChangeDetectionStrategy } from "@angular/core";
@Component({
selector: "app-manual",
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<div>{{ value }}</div>`,
})
export class ManualComponent {
value = 0;
constructor(private cdr: ChangeDetectorRef) {}
updateValue() {
// Update without triggering CD
this.value = Math.random();
// Manually trigger
this.cdr.markForCheck();
}
detachCD() {
// Stop automatic checks
this.cdr.detach();
}
reattachCD() {
// Resume automatic checks
this.cdr.reattach();
}
}
Signals and Change Detection
Automatic Fine-Grained Updates
import { Component, signal } from "@angular/core";
@Component({
selector: "app-signal-component",
// No changeDetection config needed!
template: `
<div>{{ count() }}</div>
<button (click)="increment()">+1</button>
`,
})
export class SignalComponent {
count = signal(0);
increment() {
this.count.update((n) => n + 1);
// Angular knows exactly what changed!
}
}
Benefits:
- No manual OnPush configuration
- Precise updates (only affected views)
- No immutability concerns
- Better performance
Zoneless Change Detection
Traditional Zone.js
// Zone.js patches browser APIs
button.addEventListener("click", () => {
this.value++;
// Zone.js triggers change detection automatically
});
Zoneless with Signals
import { Component, signal, ChangeDetectionStrategy } from "@angular/core";
@Component({
selector: "app-zoneless",
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div>{{ count() }}</div>
<button (click)="increment()">+1</button>
`,
})
export class ZonelessComponent {
count = signal(0);
increment() {
this.count.update((n) => n + 1);
// Signals trigger precise updates without Zone.js
}
}
Enable zoneless (Angular 18+):
// main.ts
import { bootstrapApplication } from "@angular/platform-browser";
import { provideExperimentalZonelessChangeDetection } from "@angular/core";
bootstrapApplication(AppComponent, {
providers: [provideExperimentalZonelessChangeDetection()],
});
Performance Comparison
Benchmark Example
import { Component, signal, ChangeDetectionStrategy } from "@angular/core";
@Component({
selector: "app-benchmark",
standalone: true,
template: `
<div class="benchmark">
<h2>Change Detection Benchmark</h2>
<!-- Default Strategy -->
<app-default-list [items]="items" />
<p>Time: {{ defaultTime }}ms</p>
<!-- OnPush Strategy -->
<app-onpush-list [items]="items" />
<p>Time: {{ onPushTime }}ms</p>
<!-- Signals -->
<app-signal-list [items]="itemsSignal()" />
<p>Time: {{ signalTime }}ms</p>
</div>
`,
})
export class BenchmarkComponent {
items = Array.from({ length: 1000 }, (_, i) => ({ id: i, value: i }));
itemsSignal = signal(this.items);
defaultTime = 0;
onPushTime = 0;
signalTime = 0;
}
Typical Results:
Default: ~50ms (checks 1000 components)
OnPush: ~5ms (checks only changed components)
Signals: ~0.5ms (updates only affected bindings)
Best Practices
✅ Do
// Use OnPush by default
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
// ...
})
// Use signals for reactive state
count = signal(0);
doubled = computed(() => this.count() * 2);
// Immutable updates with OnPush
updateUser(changes: Partial<User>) {
this.user = { ...this.user, ...changes };
}
// Detach CD for high-frequency updates
constructor(private cdr: ChangeDetectorRef) {
this.cdr.detach();
// Manual updates every second
setInterval(() => {
this.value = Date.now();
this.cdr.detectChanges();
}, 1000);
}
❌ Don't
// Don't mutate with OnPush
this.user.name = 'New'; // Won't trigger CD
// Don't overuse manual CD
ngAfterViewInit() {
this.cdr.detectChanges(); // Usually unnecessary
}
// Don't mix signals and manual CD
count = signal(0);
increment() {
this.count.set(this.count() + 1);
this.cdr.markForCheck(); // Redundant!
}
Real-World Example
import { Component, signal, computed, ChangeDetectionStrategy } from "@angular/core";
interface Product {
id: number;
name: string;
price: number;
category: string;
}
@Component({
selector: "app-product-list",
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="filters">
<input [value]="searchTerm()" (input)="searchTerm.set($any($event.target).value)" placeholder="Search..." />
<select (change)="category.set($any($event.target).value)">
<option value="">All Categories</option>
<option value="electronics">Electronics</option>
<option value="books">Books</option>
</select>
</div>
<div class="results">Showing {{ filteredProducts().length }} of {{ products().length }}</div>
<div class="product-grid">
@for (product of filteredProducts(); track product.id) {
<div class="product-card">
<h3>{{ product.name }}</h3>
<p>\${{ product.price }}</p>
<span class="category">{{ product.category }}</span>
</div>
}
</div>
`,
})
export class ProductListComponent {
// Signal state
products = signal<Product[]>([
{ id: 1, name: "Laptop", price: 999, category: "electronics" },
{ id: 2, name: "Book", price: 19, category: "books" },
// ... more products
]);
searchTerm = signal("");
category = signal("");
// Computed filtering (efficient!)
filteredProducts = computed(() => {
const term = this.searchTerm().toLowerCase();
const cat = this.category();
return this.products().filter((p) => {
const matchesTerm = p.name.toLowerCase().includes(term);
const matchesCat = !cat || p.category === cat;
return matchesTerm && matchesCat;
});
});
// Only filteredProducts recomputes when dependencies change
// Angular updates only the affected DOM nodes
}
Interview Questions
Q: What's the difference between Default and OnPush? A: Default checks the entire tree on every event. OnPush only checks when inputs change (by reference), component events fire, or manual trigger.
Q: Why use signals over OnPush? A: Signals provide automatic fine-grained reactivity without immutability requirements or manual CD triggers, and work seamlessly in zoneless mode.
Q: When would you detach change detection? A: For components with high-frequency updates (animations, real-time data) where you want full control over when to render.