cyberangles guide

Angular Change Detection: How It Works

At the heart of every reactive web framework lies a critical mechanism: the ability to update the user interface (UI) when underlying data changes. In Angular, this mechanism is known as **Change Detection**. Whether you’re building a simple to-do app or a complex enterprise solution, understanding how Angular detects and propagates changes is key to writing efficient, performant, and bug-free applications. Change detection ensures that when your component’s data (model) changes, the DOM (view) updates automatically to reflect those changes. Without it, you’d have to manually manipulate the DOM every time data changes—a tedious and error-prone process. Angular’s change detection system is both powerful and flexible, but its "magic" can feel opaque to developers new to the framework. In this blog, we’ll demystify Angular’s change detection: how it works under the hood, the strategies Angular uses, how to optimize it, and common pitfalls to avoid. By the end, you’ll have a clear understanding of how to leverage change detection to build fast, responsive Angular apps.

Table of Contents

  1. What is Change Detection?
  2. How Angular Tracks Changes
  3. The Change Detection Cycle
  4. Default Change Detection Strategy
  5. OnPush Change Detection Strategy
  6. Immutability: The Key to Efficient Change Detection
  7. Performance Optimization Tips
  8. Common Pitfalls and How to Avoid Them
  9. Conclusion
  10. References

1. What is Change Detection?

Change detection is the process by which Angular synchronizes the data in your components (the model) with the user interface (the view). Whenever the data in a component changes (e.g., a user clicks a button, an HTTP request returns data, or a timer elapses), Angular detects these changes and updates the DOM to reflect the new state.

Example: A Simple Counter

Consider a component with a counter that increments when a button is clicked:

// counter.component.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-counter',
  template: `
    <h2>Count: {{ count }}</h2>
    <button (click)="increment()">Increment</button>
  `
})
export class CounterComponent {
  count = 0;

  increment() {
    this.count++; // Data change
  }
}

When the user clicks “Increment”, count increases. Angular detects this change and updates the {{ count }} binding in the template, refreshing the displayed value. This seamless synchronization is the work of change detection.

2. How Angular Tracks Changes

Angular needs to know when to run change detection. To do this, it leverages a library called Zone.js (via NgZone), which monkey-patches browser APIs (like setTimeout, fetch, and event listeners) to track asynchronous operations.

Key Concept: NgZone

NgZone creates a “zone” (a execution context) for Angular applications. Any asynchronous operation (e.g., click, setTimeout, HTTP request) that occurs within this zone triggers Angular to run change detection. This is why, in the counter example, clicking the button (an asynchronous event) automatically updates the view—NgZone detects the event and tells Angular to check for changes.

The Change Detector Tree

Angular creates a change detector for every component. These detectors form a tree mirroring the component tree. Each detector is responsible for checking changes in its component’s template bindings (e.g., {{ count }}, [input]="value").

3. The Change Detection Cycle

The change detection process follows a predictable sequence:

  1. An event occurs (e.g., user input, timer, HTTP response).
  2. NgZone detects the event and signals Angular to run change detection.
  3. Change detection runs from root to leaves: Angular traverses the component tree starting from the root component, checking each component’s change detector.
  4. Check for changes: For each component, the detector compares the current value of template expressions (e.g., {{ count }}) with their previous values.
  5. Update the DOM: If a change is detected, the DOM is updated to reflect the new value.
  6. Cycle completes: The process finishes once all components are checked.

Key Note: Unidirectional Flow

Change detection in Angular is unidirectional: it flows from parent to child components. This prevents circular dependencies and makes the process predictable.

4. Default Change Detection Strategy

By default, Angular uses the ChangeDetectionStrategy.Default strategy. With this strategy:

  • Every component is checked on every change detection cycle, regardless of whether its inputs or state have changed.

This is simple but can be inefficient for large apps with many components, as unnecessary checks waste CPU resources.

How Default Strategy Works

For a component using Default, Angular will:

  • Check all template expressions (e.g., {{ user.name }}, [disabled]="isDisabled").
  • Check all input properties (@Input()) for value changes (even if the reference is the same).

Example:

// user.component.ts
import { Component, ChangeDetectionStrategy } from '@angular/core';

@Component({
  selector: 'app-user',
  template: `{{ user.name }}`,
  changeDetection: ChangeDetectionStrategy.Default // Default (can be omitted)
})
export class UserComponent {
  @Input() user: { name: string };
}

If the parent component passes user as { name: 'Alice' }, and later mutates it to { name: 'Bob' } (same reference), the Default strategy will detect the value change and update the view.

5. OnPush Change Detection Strategy

To optimize performance, Angular provides ChangeDetectionStrategy.OnPush, a more restrictive strategy that only runs change detection for a component under specific conditions:

When OnPush Triggers Change Detection

A component with OnPush will run change detection if:

  1. One of its input properties changes reference (not just value).
  2. An event occurs within the component (e.g., click, submit).
  3. ChangeDetectorRef.markForCheck() is called (manually trigger a check).
  4. ChangeDetectorRef.detectChanges() is called (force a check for this component and its children).

Key: Input Reference Changes

With OnPush, Angular only cares about reference changes for inputs, not value changes. If you mutate an input object (e.g., user.name = 'Bob'), Angular won’t detect the change because the object’s reference remains the same.

Example: OnPush in Action

// user.component.ts
import { Component, ChangeDetectionStrategy, Input } from '@angular/core';

@Component({
  selector: 'app-user',
  template: `{{ user.name }}`,
  changeDetection: ChangeDetectionStrategy.OnPush // Use OnPush
})
export class UserComponent {
  @Input() user: { name: string };
}

Parent Component:

// parent.component.ts
import { Component } from '@angular/core';

@Component({
  template: `<app-user [user]="user"></app-user>`
})
export class ParentComponent {
  user = { name: 'Alice' };

  // Case 1: Mutate the user object (same reference)
  mutateUser() {
    this.user.name = 'Bob'; // OnPush: No change detected (reference unchanged)
  }

  // Case 2: Replace the user object (new reference)
  replaceUser() {
    this.user = { name: 'Bob' }; // OnPush: Change detected (new reference)
  }
}

In mutateUser(), the user object’s reference doesn’t change, so OnPush doesn’t update the view. In replaceUser(), a new object is created (new reference), so OnPush triggers a check and updates the view.

6. Immutability: The Key to Efficient Change Detection

For OnPush to work effectively, you must use immutable data structures. Immutable data means you never mutate existing objects/arrays—instead, you create new ones when data changes.

Why Immutability Matters

  • Predictable change detection: With immutability, a change in data is always signaled by a new reference, making it easy for OnPush to detect.
  • Performance: Avoids unnecessary checks and makes change detection faster.

Examples of Immutability

Objects: Use Spread Operator or Object.assign

// Mutation (bad for OnPush)
this.user.name = 'Bob'; 

// Immutable update (good for OnPush)
this.user = { ...this.user, name: 'Bob' }; // New reference

Arrays: Use map, filter, or Spread Operator

// Mutation (bad)
this.items.push(newItem); 

// Immutable update (good)
this.items = [...this.items, newItem]; // New array reference

7. Performance Optimization Tips

1. Use OnPush for Most Components

Adopt OnPush for components where inputs change infrequently or can be made immutable. This reduces the number of checks during each cycle.

2. Use Pure Pipes for Transformations

Pure pipes (the default) run only when their input references change. They are cached and avoid redundant computations.

// pure.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({ name: 'uppercase' }) // Pure by default
export class UppercasePipe implements PipeTransform {
  transform(value: string): string {
    return value.toUpperCase();
  }
}

3. Use trackBy with *ngFor

*ngFor re-renders all items by default when the array changes. trackBy lets you specify a unique identifier for items, so only changed items are re-rendered.

<!-- user-list.component.html -->
<ul>
  <li *ngFor="let user of users; trackBy: trackByUserId">
    {{ user.name }}
  </li>
</ul>
// user-list.component.ts
trackByUserId(index: number, user: { id: number }): number {
  return user.id; // Unique identifier
}

4. Detach/Reattach Change Detectors

For components that rarely change, detach their change detector to stop checks, then reattach when needed:

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

@Component({ ... })
export class StaticComponent {
  constructor(private cdr: ChangeDetectorRef) {
    this.cdr.detach(); // Stop change detection
  }

  updateData() {
    this.data = newData;
    this.cdr.reattach(); // Re-enable checks
    this.cdr.detectChanges(); // Manually run check
  }
}

5. Use markForCheck() for OnPush Components

If an OnPush component’s internal state changes (not via inputs), use markForCheck() to tell Angular to check it:

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

@Component({
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class InternalStateComponent {
  isUpdated = false;

  constructor(private cdr: ChangeDetectorRef) {}

  updateState() {
    this.isUpdated = true;
    this.cdr.markForCheck(); // Trigger change detection for this component
  }
}

8. Common Pitfalls and How to Avoid Them

Pitfall 1: Mutating Data Instead of Replacing It

Problem: Mutating objects/arrays doesn’t trigger OnPush change detection.
Fix: Use immutable updates (spread operator, map, etc.).

Pitfall 2: Overusing Default Strategy

Problem: Unnecessary checks slow down the app.
Fix: Use OnPush for components with stable inputs.

Pitfall 3: Expensive Operations in Templates

Problem: Complex calculations in templates (e.g., {{ getTotal() }}) run on every change detection cycle.
Fix: Cache results or move logic to a pure pipe.

Pitfall 4: Forgetting markForCheck() with OnPush

Problem: Internal state changes in OnPush components don’t update the view.
Fix: Call this.cdr.markForCheck() after updating state.

9. Conclusion

Understanding Angular’s change detection is critical for building high-performance applications. By leveraging OnPush strategy, immutability, and optimization techniques like trackBy and pure pipes, you can ensure your app remains fast and responsive even as it scales.

Key takeaways:

  • Change detection synchronizes the model and view.
  • NgZone triggers change detection after async events.
  • OnPush is optimal for most components and relies on immutable inputs.
  • Avoid mutations and use markForCheck()/detectChanges() for manual control.

10. References


I hope this guide helps you master Angular change detection! Let me know in the comments if you have questions or tips to share. 🚀