Module Overview
Estimated Time: 3-4 hours | Difficulty: Advanced | Prerequisites: Module 9
- How change detection works
- OnPush strategy
- Signals and zoneless change detection
- Performance profiling tools
- Optimization techniques
- Memory management
How Change Detection Works
Copy
┌─────────────────────────────────────────────────────────────────────────┐
│ Change Detection Cycle │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ Trigger Events: │
│ • User events (click, input, etc.) │
│ • HTTP responses │
│ • setTimeout/setInterval │
│ • Promises resolving │
│ │
│ ┌────────────────┐ │
│ │ Zone.js │ ← Monkey-patches async APIs │
│ │ detects async │ │
│ └───────┬────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────┐ │
│ │ Triggers CD │ │
│ │ from root │ │
│ └───────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Component Tree │ │
│ │ │ │
│ │ ┌─────────────────┐ │ │
│ │ │ AppComponent │ ← Check │ │
│ │ └────────┬────────┘ │ │
│ │ │ │ │
│ │ ┌───────────┼───────────┐ │ │
│ │ ▼ ▼ ▼ │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
│ │ │ Header │ │ Content │ │ Footer │ ← Check all │ │
│ │ └─────────┘ └────┬────┘ └─────────┘ │ │
│ │ │ │ │
│ │ ┌───────┼───────┐ │ │
│ │ ▼ ▼ ▼ │ │
│ │ ┌─────┐ ┌─────┐ ┌─────┐ │ │
│ │ │Child│ │Child│ │Child│ ← Check all │ │
│ │ └─────┘ └─────┘ └─────┘ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ Default: Every component checked on every cycle │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Default vs OnPush Strategy
Default Strategy
Copy
@Component({
selector: 'app-counter',
template: `
<p>Count: {{ count }}</p>
<button (click)="increment()">+</button>
`
// changeDetection: ChangeDetectionStrategy.Default (implicit)
})
export class CounterComponent {
count = 0;
increment() {
this.count++; // Triggers CD for entire tree
}
}
OnPush Strategy
Copy
import { ChangeDetectionStrategy, Component, input, signal } from '@angular/core';
@Component({
selector: 'app-user-card',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="card">
<h3>{{ user().name }}</h3>
<p>{{ user().email }}</p>
<span>Local: {{ localCount() }}</span>
</div>
`
})
export class UserCardComponent {
// Signal inputs work with OnPush
user = input.required<User>();
// Signals trigger CD automatically
localCount = signal(0);
increment() {
this.localCount.update(c => c + 1); // Triggers CD for this component
}
}
OnPush Triggers
Copy
┌─────────────────────────────────────────────────────────────────────────┐
│ OnPush Change Detection Triggers │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ Component marked for check when: │
│ │
│ 1. Input reference changes (not mutation!) │
│ @Input() user: User; │
│ // ✓ parent.user = { ...user, name: 'new' } (new reference) │
│ // ✗ parent.user.name = 'new' (same reference, won't detect) │
│ │
│ 2. Event from component or child │
│ <button (click)="onClick()"> ← Marks component │
│ │
│ 3. Async pipe emits │
│ {{ data$ | async }} ← Marks component when observable emits │
│ │
│ 4. Signal value changes │
│ count = signal(0); │
│ this.count.set(1); ← Marks component │
│ │
│ 5. Manual trigger │
│ this.cdr.markForCheck(); │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Manual Change Detection
Copy
import {
ChangeDetectorRef,
ChangeDetectionStrategy,
Component,
inject
} from '@angular/core';
@Component({
selector: 'app-data-viewer',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div>{{ data }}</div>
<button (click)="loadData()">Load</button>
`
})
export class DataViewerComponent {
private cdr = inject(ChangeDetectorRef);
data: any;
loadData() {
// External data change (not detected by OnPush)
externalLibrary.getData().then(result => {
this.data = result;
// Option 1: Mark for check (will run on next CD cycle)
this.cdr.markForCheck();
// Option 2: Detect changes immediately
// this.cdr.detectChanges();
});
}
// Detach from change detection tree
ngOnInit() {
// Component won't be checked unless manually triggered
this.cdr.detach();
}
// Reattach to change detection tree
resume() {
this.cdr.reattach();
}
}
Signals and Change Detection
Signals provide fine-grained reactivity:Copy
@Component({
selector: 'app-dashboard',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="dashboard">
<app-stats [count]="itemCount()" />
<app-chart [data]="chartData()" />
<app-list [items]="filteredItems()" />
</div>
`
})
export class DashboardComponent {
// Source signals
items = signal<Item[]>([]);
filter = signal('');
// Computed signals (cached, only recalculated when dependencies change)
filteredItems = computed(() => {
const filter = this.filter().toLowerCase();
return this.items().filter(item =>
item.name.toLowerCase().includes(filter)
);
});
itemCount = computed(() => this.filteredItems().length);
chartData = computed(() => {
return this.items().map(item => ({
label: item.name,
value: item.quantity
}));
});
// Only affects template bindings that use changed signal
updateFilter(term: string) {
this.filter.set(term);
// Only filteredItems and itemCount recompute
// chartData is NOT recomputed (different dependency)
}
}
Zoneless Angular (Experimental)
Angular 18+ supports running without Zone.js:Copy
// app.config.ts
import { ApplicationConfig, provideExperimentalZonelessChangeDetection } from '@angular/core';
export const appConfig: ApplicationConfig = {
providers: [
provideExperimentalZonelessChangeDetection()
]
};
Copy
// Components must use signals or manual CD
@Component({
selector: 'app-counter',
standalone: true,
template: `
<button (click)="decrement()">-</button>
<span>{{ count() }}</span>
<button (click)="increment()">+</button>
`
})
export class CounterComponent {
count = signal(0);
increment() {
this.count.update(c => c + 1); // Triggers CD via signal
}
decrement() {
this.count.update(c => c - 1);
}
}
Zoneless is experimental. Use signals for all reactive state when going zoneless. Async operations must explicitly trigger change detection.
Performance Optimization Techniques
1. TrackBy for ngFor / @for
Copy
@Component({
template: `
<!-- Modern @for with track -->
@for (item of items(); track item.id) {
<app-item-card [item]="item" />
}
<!-- Legacy ngFor with trackBy -->
<app-item-card
*ngFor="let item of items; trackBy: trackById"
[item]="item"
/>
`
})
export class ListComponent {
items = signal<Item[]>([]);
trackById(index: number, item: Item) {
return item.id;
}
}
2. Lazy Loading
Copy
// Defer loading of heavy components
@Component({
template: `
@defer (on viewport) {
<app-heavy-chart [data]="chartData()" />
} @placeholder {
<div class="chart-placeholder">Loading chart...</div>
}
@defer (on interaction) {
<app-comment-section [postId]="postId()" />
} @placeholder {
<button>Load Comments</button>
}
`
})
export class PostComponent {
chartData = signal<ChartData[]>([]);
postId = input.required<number>();
}
3. Pure Pipes
Copy
// Pure pipe (default) - only recalculates when input reference changes
@Pipe({ name: 'filterItems', standalone: true, pure: true })
export class FilterItemsPipe implements PipeTransform {
transform(items: Item[], filter: string): Item[] {
console.log('Filter pipe called'); // Only when items/filter reference changes
return items.filter(item =>
item.name.toLowerCase().includes(filter.toLowerCase())
);
}
}
// Usage
@Component({
template: `
@for (item of items() | filterItems:filter(); track item.id) {
<div>{{ item.name }}</div>
}
`
})
4. Avoid Complex Template Expressions
Copy
// ❌ Bad: Function called on every CD cycle
@Component({
template: `
<div>{{ getFullName() }}</div>
<div>{{ calculateTotal() }}</div>
`
})
class BadComponent {
getFullName() {
console.log('Called!'); // Called many times
return `${this.firstName} ${this.lastName}`;
}
}
// ✅ Good: Use computed signals
@Component({
template: `
<div>{{ fullName() }}</div>
<div>{{ total() }}</div>
`
})
class GoodComponent {
firstName = signal('John');
lastName = signal('Doe');
items = signal<Item[]>([]);
fullName = computed(() => `${this.firstName()} ${this.lastName()}`);
total = computed(() => this.items().reduce((sum, i) => sum + i.price, 0));
}
5. Virtual Scrolling
Copy
import { ScrollingModule } from '@angular/cdk/scrolling';
@Component({
selector: 'app-virtual-list',
standalone: true,
imports: [ScrollingModule],
template: `
<cdk-virtual-scroll-viewport itemSize="50" class="viewport">
<div *cdkVirtualFor="let item of items; trackBy: trackById" class="item">
{{ item.name }}
</div>
</cdk-virtual-scroll-viewport>
`,
styles: [`
.viewport {
height: 400px;
width: 100%;
}
.item {
height: 50px;
}
`]
})
export class VirtualListComponent {
items = signal<Item[]>([]);
trackById(index: number, item: Item) {
return item.id;
}
}
Profiling and Debugging
Angular DevTools
Copy
// Install Angular DevTools browser extension
// Features:
// - Component tree inspection
// - Change detection profiling
// - Dependency injection debugging
// - Performance flame charts
// Enable detailed profiling in development
// angular.json
{
"projects": {
"my-app": {
"architect": {
"build": {
"configurations": {
"development": {
"optimization": false,
"sourceMap": true
}
}
}
}
}
}
}
Performance Profiling
Copy
// Measure component performance
@Component({...})
export class PerformanceTestComponent {
ngDoCheck() {
console.time('MyComponent CD');
}
ngAfterViewChecked() {
console.timeEnd('MyComponent CD');
}
}
// Use Chrome Performance tab
// 1. Open DevTools → Performance
// 2. Click Record
// 3. Perform actions
// 4. Stop recording
// 5. Analyze flame chart
// Programmatic performance marks
export class DataService {
loadData() {
performance.mark('data-load-start');
return this.http.get('/api/data').pipe(
tap(() => {
performance.mark('data-load-end');
performance.measure('data-load', 'data-load-start', 'data-load-end');
const measure = performance.getEntriesByName('data-load')[0];
console.log(`Data loaded in ${measure.duration}ms`);
})
);
}
}
Memory Management
Subscription Cleanup
Copy
// Option 1: takeUntilDestroyed
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({...})
export class DataComponent {
private destroyRef = inject(DestroyRef);
ngOnInit() {
this.dataService.getData()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(data => this.handleData(data));
}
}
// Option 2: Async pipe (automatic cleanup)
@Component({
template: `
@if (data$ | async; as data) {
<div>{{ data.name }}</div>
}
`
})
export class AutoCleanupComponent {
data$ = this.dataService.getData();
}
// Option 3: Signals with toSignal (automatic cleanup)
@Component({
template: `<div>{{ data()?.name }}</div>`
})
export class SignalComponent {
data = toSignal(this.dataService.getData());
}
Detecting Memory Leaks
Copy
// Chrome DevTools → Memory tab
// 1. Take heap snapshot before action
// 2. Perform action (navigate away and back)
// 3. Take heap snapshot after
// 4. Compare snapshots
// Common leak patterns:
// ❌ Event listeners not removed
// ❌ Subscriptions not unsubscribed
// ❌ setInterval not cleared
// ❌ Closures holding references
// Debug helper
@Injectable({ providedIn: 'root' })
export class LeakDetector {
private activeComponents = new Set<string>();
register(name: string) {
this.activeComponents.add(name);
console.log('Active components:', [...this.activeComponents]);
}
unregister(name: string) {
this.activeComponents.delete(name);
console.log('Active components:', [...this.activeComponents]);
}
}
Performance Checklist
Copy
┌─────────────────────────────────────────────────────────────────────────┐
│ Performance Optimization Checklist │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ Change Detection: │
│ □ Use OnPush for all presentational components │
│ □ Use signals for reactive state │
│ □ Avoid function calls in templates │
│ □ Use computed signals for derived values │
│ │
│ Loading: │
│ □ Lazy load routes │
│ □ Use @defer for heavy components │
│ □ Preload critical modules │
│ □ Optimize bundle size │
│ │
│ Rendering: │
│ □ Use trackBy with *ngFor / track with @for │
│ □ Virtual scroll for long lists │
│ □ Minimize DOM size │
│ □ Use CSS containment │
│ │
│ Data: │
│ □ Use pure pipes │
│ □ Cache HTTP responses │
│ □ Paginate large datasets │
│ □ Debounce user input │
│ │
│ Memory: │
│ □ Unsubscribe from observables │
│ □ Clear intervals/timeouts │
│ □ Remove event listeners │
│ □ Profile for memory leaks │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Practice Exercise
Exercise: Optimize a Slow Component
Given a slow list component, optimize it using:
- OnPush change detection
- Signals for state
- Virtual scrolling
- Proper tracking
- Computed values instead of methods
Solution
Solution
Copy
// Before: Slow component
@Component({
template: `
<input (input)="filter = $event.target.value" />
<div *ngFor="let item of getFilteredItems()">
<app-item [item]="item" [isSelected]="isSelected(item)" />
</div>
<div>Total: {{ getTotal() }}</div>
`
})
class SlowComponent {
items: Item[] = [];
filter = '';
selectedIds: number[] = [];
getFilteredItems() {
return this.items.filter(i => i.name.includes(this.filter));
}
isSelected(item: Item) {
return this.selectedIds.includes(item.id);
}
getTotal() {
return this.getFilteredItems().reduce((s, i) => s + i.price, 0);
}
}
// After: Optimized component
@Component({
selector: 'app-optimized-list',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ScrollingModule],
template: `
<input (input)="updateFilter($event)" />
<cdk-virtual-scroll-viewport itemSize="60" class="viewport">
<app-item
*cdkVirtualFor="let item of filteredItems(); trackBy: trackById"
[item]="item"
[isSelected]="selectedIdSet().has(item.id)"
/>
</cdk-virtual-scroll-viewport>
<div class="footer">
<span>Showing {{ filteredItems().length }} of {{ items().length }}</span>
<span>Total: {{ total() | currency }}</span>
</div>
`,
styles: [`
.viewport { height: 400px; }
`]
})
class OptimizedComponent {
// Source signals
items = signal<Item[]>([]);
filter = signal('');
selectedIds = signal<number[]>([]);
// Computed values (cached)
filteredItems = computed(() => {
const filterTerm = this.filter().toLowerCase();
if (!filterTerm) return this.items();
return this.items().filter(item =>
item.name.toLowerCase().includes(filterTerm)
);
});
// Set for O(1) lookup
selectedIdSet = computed(() => new Set(this.selectedIds()));
total = computed(() =>
this.filteredItems().reduce((sum, item) => sum + item.price, 0)
);
updateFilter(event: Event) {
const value = (event.target as HTMLInputElement).value;
this.filter.set(value);
}
trackById(index: number, item: Item) {
return item.id;
}
}
Summary
1
Change Detection
Angular checks component tree from root; use OnPush to optimize
2
OnPush Strategy
Only checks when inputs change reference, events occur, or signals update
3
Signals
Provide fine-grained reactivity with automatic change detection
4
Optimization
Use trackBy, virtual scrolling, lazy loading, and pure pipes
5
Profiling
Use Angular DevTools and Chrome Performance for analysis
Next Steps
Next: Testing in Angular
Learn unit testing, component testing, and E2E testing