Files
gh-ehssanatassi-angular-mar…/skills/change-detection/SKILL.md
2025-11-29 18:25:00 +08:00

15 KiB
Raw Blame History

Angular Change Detection Optimization

Comprehensive guide to Angular change detection, OnPush strategy, Signals, and Zone.js optimization.

Table of Contents

  1. How Change Detection Works
  2. Default vs OnPush
  3. OnPush Strategy Patterns
  4. Angular Signals
  5. Zone.js Optimization
  6. Manual Change Detection
  7. Common Pitfalls
  8. 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

  1. Event occurs (click, HTTP response, timer)
  2. Zone.js intercepts the async operation
  3. Angular runs change detection from root
  4. Each component checks if bindings changed
  5. 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:
    1. @Input() reference changes
    2. Event handler in component template
    3. Observable emits with async pipe
    4. 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.OnPush by default
  • Use async pipe for observables
  • Use Angular Signals for reactive state
  • Create new references for objects/arrays
  • Use trackBy for 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! 🚀