Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

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. Change detection is Angular’s mechanism for keeping the screen in sync with your data. Every time something happens — a click, an HTTP response, a timer — Angular needs to check if any data changed and update the DOM accordingly. In a small app, this is imperceptible. In a large app with hundreds of components, inefficient change detection is the number one cause of janky scrolling, slow interactions, and frustrated users. This module teaches you how to make Angular only check what actually changed. 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 as of Angular 18, but it is the direction Angular is heading. The benefit is significant: removing Zone.js eliminates the monkey-patching of every async API (setTimeout, Promise, addEventListener, etc.), which reduces bundle size by ~15KB and avoids unexpected change detection triggered by third-party libraries. The trade-off: you must use signals or manual ChangeDetectorRef calls for all state that affects the template. If you are starting a new project and your team is comfortable with signals, it is worth experimenting with — but have a fallback plan.

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 change detection cycle.
// If you open DevTools and watch the console, you will see "Called!"
// printed dozens of times per second -- even when the name has not
// changed. Angular has no way to know the return value is the same
// without actually calling the function every time.
@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. The computed value is cached and only
// recalculates when its dependencies (firstName or lastName) actually
// change. Between changes, Angular reads the cached value -- zero
// wasted computation, even during rapid change detection cycles.
@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

Interview Deep-Dive

Strong Answer: OnPush means Angular will only check this component when one of these specific things happens: an @Input reference changes (not mutation), an event originates from this component or its children, an async pipe receives a new emission, a signal used in the template changes, or you manually call markForCheck() or detectChanges().The common scenario: you fetch data with subscribe() and assign the result to a plain class property. With Default strategy, this works because Angular checks everything. With OnPush, none of the above triggers fire — the HTTP response is handled in a subscription callback, the property is not a signal, and no event or async pipe is involved. The view is stale.The fixes, ranked from best to worst: use toSignal() to convert the observable to a signal (signals trigger OnPush). Use the async pipe in the template (it calls markForCheck internally). Convert the property to a signal and use signal.set(). Inject ChangeDetectorRef and call markForCheck() in the subscribe callback. The last option is a code smell — if you find yourself calling markForCheck frequently, you are fighting OnPush instead of working with it.The broader lesson: OnPush works beautifully when your data flow is either signal-based or pipe-based. It fights back when you use imperative subscribe-and-assign patterns.Follow-up: What is the difference between markForCheck() and detectChanges()? Answer: markForCheck() marks the component and all its ancestors as “dirty” — they will be checked on the next change detection cycle (whenever Angular decides to run one). It is safe to call from anywhere. detectChanges() immediately runs change detection synchronously on this component and its children, right now. It is more aggressive and can cause issues if called during an ongoing change detection cycle (ExpressionChangedAfterItHasBeenCheckedError). I use markForCheck 99% of the time; detectChanges only when I need the DOM to update before the next line of code executes, like when measuring element dimensions after a data change.
Strong Answer: Zone.js works by monkey-patching every async API so Angular can detect when async operations complete and trigger change detection globally. It is a blunt instrument — a single setTimeout in a third-party library triggers a full component tree check. Signals offer a surgical alternative: they track exactly which template bindings depend on which data, and only mark those specific components for check when the data changes.With zoneless Angular (provideExperimentalZonelessChangeDetection), Zone.js is not loaded. No monkey-patching occurs. Angular only knows about changes through signals, markForCheck() calls, or event handlers. If you have a plain class property that changes in a setTimeout callback, the template never updates — there is no Zone.js to detect the setTimeout completion.What breaks: any code that relies on automatic change detection after async operations. This includes subscribe-and-assign patterns, setTimeout/setInterval callbacks that modify component state, Promise.then handlers that update data, and third-party libraries that use async APIs internally. All of these need to either use signals, the async pipe, or explicit markForCheck/detectChanges calls.The practical migration path: first, convert all template-bound state to signals. Then switch to OnPush on all components (which is a prerequisite). Then enable zoneless. If your app already uses signals and OnPush everywhere, the zoneless switch is nearly free.Follow-up: What is the performance benefit of removing Zone.js? Answer: Two benefits. First, bundle size drops by roughly 13-15KB (gzipped) because you no longer ship Zone.js. Second, and more importantly, you eliminate phantom change detection cycles. In a zone-based app, every setTimeout, fetch, and addEventListener from every library triggers change detection. I have profiled apps where third-party charting libraries caused 50+ unnecessary change detection cycles per second. Without Zone.js, those async operations are invisible to Angular unless you explicitly use signals. The result is significantly less work for the framework and smoother 60fps rendering.
Strong Answer: Method calls in templates are recalculated on every change detection cycle because Angular has no way to know if the return value changed without actually calling the method. If change detection runs 30 times per second (which is normal during user interaction), getTotal() executes 30 times per second even if the underlying data has not changed. If getFilteredItems() iterates a 1,000-item array, you are doing 30,000 array iterations per second for nothing.The refactoring depends on the context. If the method is a pure derivation of other data, replace it with a computed signal. fullName = computed(() => this.firstName() + ’ ’ + this.lastName()) only recalculates when firstName or lastName signals actually change, and the result is cached between reads. If the data comes from observables, use a pure pipe — pipes with pure: true (the default) only re-execute when the input reference changes.For the specific case of filtering: instead of calling a method in the template, I create a filteredItems computed signal that derives from the items signal and the filter signal. The template reads filteredItems() which returns the cached result. When items or filter change, the computed recalculates once and caches again.The key mental model shift: templates should only read values, never compute them. All computation should happen in the component class (via computed signals) or in pure pipes.Follow-up: What about getter properties — are they safer than methods in templates? Answer: No, getters have the exact same problem. A getter in TypeScript is syntactic sugar for a method call — Angular calls it on every change detection cycle. The only difference is visual: user.fullName looks like a property access but executes code. The fix is the same: replace with a computed signal.

Next Steps

Next: Testing in Angular

Learn unit testing, component testing, and E2E testing