Docs
/
Angular
Chapter 12

12 — Change Detection

How Change Detection Works

Angular checks the component tree to see if the DOM needs updating. By default, it checks every component from root to leaves on every event.

User clicks button
  → Zone.js patches the event
  → Angular triggers change detection
  → Checks AppComponent → checks all children → updates DOM

Change Detection Strategies

StrategyBehavior
DefaultCheck this component on every CD cycle
OnPushCheck only when inputs change, events fire, or signals update

Default Strategy (Avoid in Production)

@Component({
  changeDetection: ChangeDetectionStrategy.Default,   // This is the default
})
  • Checks on every browser event (click, scroll, timer, HTTP, etc.)
  • Easy to use but slow in large apps
  • Every component's template bindings are re-evaluated

OnPush Strategy (Recommended)

import { ChangeDetectionStrategy } from '@angular/core';

@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserListComponent {
  @Input() users: User[] = [];
}

Angular only re-checks this component when:

  1. An @Input reference changes (not mutation!)
  2. An event handler fires inside this component
  3. An async pipe receives a new value
  4. A signal used in the template updates
  5. Manual trigger via ChangeDetectorRef

⚠️ Common Pitfall — Mutation vs Immutable

// ❌ Mutating the array — OnPush won't detect this
this.users.push(newUser);

// ✅ Create a new array reference — OnPush detects this
this.users = [...this.users, newUser];

// ❌ Mutating an object
this.user.name = 'New Name';

// ✅ Create a new object reference
this.user = { ...this.user, name: 'New Name' };

Manual Change Detection

import { ChangeDetectorRef } from '@angular/core';

@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MyComponent {
  private cdr = inject(ChangeDetectorRef);

  // Mark this component for check in next CD cycle
  triggerCheck() {
    this.cdr.markForCheck();
  }

  // Run CD immediately for this component and children
  forceDetect() {
    this.cdr.detectChanges();
  }

  // Detach from CD tree (manual control)
  detach() {
    this.cdr.detach();
  }

  // Reattach to CD tree
  reattach() {
    this.cdr.reattach();
  }
}

Signals + OnPush = Best Performance

Signals automatically notify Angular when values change — no Zone.js needed.

@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <p>Count: {{ count() }}</p>
    <p>Double: {{ double() }}</p>
    <button (click)="increment()">+</button>
  `,
})
export class CounterComponent {
  count = signal(0);
  double = computed(() => this.count() * 2);

  increment() {
    this.count.update(c => c + 1);
    // Angular knows count changed → re-renders this component only
  }
}

Zone.js — What It Does

Zone.js monkey-patches all async APIs to trigger change detection:

Patched by Zone.js:
  - setTimeout / setInterval
  - Promise.then
  - addEventListener (click, input, etc.)
  - XMLHttpRequest / fetch
  - requestAnimationFrame

When any of these complete, Zone.js tells Angular: "Something happened — run change detection."


Zoneless Angular (Experimental — Angular 18+)

Remove Zone.js entirely — Angular only updates when signals change or events fire.

// app.config.ts
import { provideExperimentalZonelessChangeDetection } from '@angular/core';

export const appConfig: ApplicationConfig = {
  providers: [
    provideExperimentalZonelessChangeDetection(),
    provideRouter(routes),
    provideHttpClient(),
  ],
};
// angular.json — remove zone.js polyfill
"polyfills": []   // Remove "zone.js" from here

Requirements for Zoneless:

  • ✅ All components use OnPush or signals
  • ✅ No reliance on Zone.js for triggering CD
  • ✅ Use async pipe or signals for observables
  • ❌ Don't rely on setTimeout triggering CD automatically

Performance Patterns

1. trackBy / track in Loops

<!-- ❌ Without track — Angular re-creates all DOM nodes on change -->
@for (user of users; track $index) { ... }

<!-- ✅ With unique ID — Angular reuses existing DOM nodes -->
@for (user of users; track user.id) { ... }

2. Pure Pipes Over Methods in Templates

<!-- ❌ Method called on EVERY CD cycle -->
<p>{{ calculateTotal() }}</p>

<!-- ✅ Pure pipe — only recalculates when input changes -->
<p>{{ items | total }}</p>

<!-- ✅ Computed signal — only recalculates when dependency changes -->
<p>{{ total() }}</p>

3. Detach Heavy Components

// For components that rarely change (e.g., static charts)
@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class StaticChartComponent implements AfterViewInit {
  private cdr = inject(ChangeDetectorRef);

  ngAfterViewInit() {
    this.renderChart();
    this.cdr.detach();   // Stop checking this component entirely
  }

  // Manually trigger when data changes
  updateChart(data: ChartData) {
    this.data = data;
    this.renderChart();
    this.cdr.detectChanges();
  }
}

Debugging Change Detection

// Enable Angular DevTools profiler (in Chrome DevTools)
// → Shows which components are checked and how long each takes

// Log when a component is checked
ngDoCheck() {
  console.log('UserListComponent checked');
}

Key Takeaways

  • Always use ChangeDetectionStrategy.OnPush — it's a free performance win
  • With OnPush, always use immutable data (spread operator, not mutation)
  • Signals work perfectly with OnPush and are the path to zoneless Angular
  • Never call methods in templates for computed values — use computed() or pure pipes
  • Use track item.id in @for to prevent unnecessary DOM re-creation
  • Zoneless Angular (future default) requires signals throughout — start migrating now