Skip to main content
Angular Change Detection

Module Overview

Estimated Time: 3-4 hours | Difficulty: Advanced | Prerequisites: Module 9
Understanding change detection is crucial for building performant Angular applications. This module covers how Angular detects changes, optimization strategies, and performance best practices. What You’ll Learn:
  • How change detection works
  • OnPush strategy
  • Signals and zoneless change detection
  • Performance profiling tools
  • Optimization techniques
  • Memory management

How Change Detection Works

┌─────────────────────────────────────────────────────────────────────────┐
│                    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

@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

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

┌─────────────────────────────────────────────────────────────────────────┐
│              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

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:
@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:
// app.config.ts
import { ApplicationConfig, provideExperimentalZonelessChangeDetection } from '@angular/core';

export const appConfig: ApplicationConfig = {
  providers: [
    provideExperimentalZonelessChangeDetection()
  ]
};
// 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

@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

// 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

// 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

// ❌ 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

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

// 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

// 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

// 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

// 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

┌─────────────────────────────────────────────────────────────────────────┐
│              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:
  1. OnPush change detection
  2. Signals for state
  3. Virtual scrolling
  4. Proper tracking
  5. Computed values instead of methods
// 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