Skip to content

Angular Change Detection Explained — How Angular Knows What to Update

DodaTech Updated 2026-06-28 6 min read

In this tutorial, you will learn about Angular Change Detection Explained. We cover key concepts, practical examples, and best practices to help you master this topic.

Angular change detection is the mechanism that keeps the view in sync with the component data by detecting changes and updating the DOM when necessary.

What You'll Learn

  • How Angular's default change detection works
  • What triggers change detection
  • The difference between Default and OnPush strategies
  • How to optimize with ChangeDetectorRef
  • When and why to detach or reattach change detection

Why It Matters

Understanding change detection helps you write performant Angular apps. Without this knowledge, you may create components that check unnecessary subtrees, leading to slow rendering and poor user experience.

Real-World Use

The Durga Antivirus Pro threat dashboard updates every few seconds with new scan data. Using OnPush Strategy and ChangeDetectorRef.markForCheck(), only the threat chart component re-renders instead of the entire dashboard tree, keeping the UI responsive.

flowchart TD
    A[Event / Async / Timer] --> B[Zone.js]
    B --> C[Change Detection Triggered]
    C --> D[Root Component]
    D --> E[Child 1]
    D --> F[Child 2]
    F --> G[Grandchild A]
    F --> H[Grandchild B]
    style A fill:#f97316,color:#fff

Default Change Detection

By default, Angular checks every component in the tree from top to bottom after any asynchronous activity:

import { Component } from "@angular/core";
import { CommonModule } from "@angular/common";

@Component({
  selector: "app-clock",
  standalone: true,
  imports: [CommonModule],
  template: `<p>{{ currentTime | date:"medium" }}</p>`
})
export class ClockComponent {
  currentTime = new Date();

  updateTime() {
    this.currentTime = new Date(); // Triggers change detection
  }
}

Expected output: When updateTime is called (from a click or interval), Angular re-checks the component and updates the displayed time.

Default change detection works well for small to medium apps. Angular walks the entire component tree, comparing each bound expression with its previous value. If they differ, Angular updates the DOM.

What Triggers Change Detection

Change detection runs automatically when:

import { Component, NgZone } from "@angular/core";

@Component({
  selector: "app-trigger-demo",
  standalone: true,
  template: `
    <button (click)="onClick()">Click - triggers CD</button>
    <button (click)="runOutsideAngular()">Outside Angular</button>
    <p>{{ message }}</p>
  `
})
export class TriggerDemoComponent {
  message = "Waiting...";

  constructor(private ngZone: NgZone) {}

  onClick() {
    this.message = "Button clicked at " + new Date().toLocaleTimeString();
  }

  runOutsideAngular() {
    this.ngZone.runOutsideAngular(() => {
      setTimeout(() => {
        this.message = "This change is NOT detected";
        console.log("Changed but not rendered until next CD cycle");
      }, 1000);
    });
  }
}

Expected output: Clicking the first button updates the message immediately. Clicking the second button changes the property but the view does not update until another change detection cycle runs.

Zone.js monkey-patches browser APIs (click, setTimeout, XHR, etc.) and notifies Angular to run change detection after any patched operation completes.

OnPush Change Detection

OnPush only checks a component when its inputs change, it emits an event, or an observable bound in the template emits:

import { Component, Input, ChangeDetectionStrategy } from "@angular/core";

@Component({
  selector: "app-expensive-card",
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="card">
      <h3>{{ user.name }}</h3>
      <p>{{ user.email }}</p>
      <button (click)="onSelect()">Select</button>
    </div>
  `
})
export class ExpensiveCardComponent {
  @Input() user!: { name: string; email: string };

  onSelect() {
    console.log("Selected:", this.user.name);
  }
}

Expected output: The card only re-renders when the user object reference changes, not when nested properties mutate. Clicking the button triggers change detection for this component.

OnPush is a performance optimization. Instead of checking every component on every cycle, Angular skips components that have no reason to change.

ChangeDetectorRef

Use ChangeDetectorRef to manually control change detection:

import { Component, ChangeDetectorRef } from "@angular/core";

@Component({
  selector: "app-manual-cd",
  standalone: true,
  template: `
    <p>Data: {{ data }}</p>
    <button (click)="loadData()">Load Data</button>
  `
})
export class ManualCdComponent {
  data = "";

  constructor(private cdr: ChangeDetectorRef) {}

  loadData() {
    // Simulate external async operation
    setTimeout(() => {
      this.data = "Data loaded at " + new Date().toLocaleTimeString();
      this.cdr.markForCheck(); // Notify Angular to check this component
    }, 1000);
  }

  detachDetection() {
    this.cdr.detach(); // Stop checking this component
  }

  reattachDetection() {
    this.cdr.reattach(); // Resume checking
  }
}

Expected output: With markForCheck, the component updates even if it uses OnPush. With detach, the component stops receiving change detection entirely.

detach is useful for static content that never changes. markForCheck tells Angular to check this component and its ancestors on the next cycle. detectChanges runs change detection immediately.

The Zone.js Effect

Zone.js intercepts all async APIs, but you can run code outside the zone:

import { Component, NgZone } from "@angular/core";

@Component({
  selector: "app-zone-demo",
  standalone: true,
  template: `
    <p>Updates: {{ updateCount }}</p>
    <button (click)="addOutsideZone()">Add (outside zone)</button>
  `
})
export class ZoneDemoComponent {
  updateCount = 0;

  constructor(private ngZone: NgZone) {}

  addOutsideZone() {
    this.ngZone.runOutsideAngular(() => {
      let count = 0;
      const interval = setInterval(() => {
        if (count < 100) {
          count++;
          this.ngZone.run(() => {
            this.updateCount = count;
          });
        } else {
          clearInterval(interval);
        }
      }, 100);
    });
  }
}

Expected output: The counter updates only once per 100ms instead of triggering change detection on every interval tick.

Running intensive timers or web socket events outside Angular's zone prevents unnecessary change detection cycles. Use ngZone.run() to re-enter the zone when the UI must update.

Common Mistakes

  1. Mutating objects with OnPush — If you push to an array or set a property on an object, OnPush does not detect the change because the reference stays the same. Create a new reference instead.

  2. Overusing detach() — Detaching change detection makes the component unresponsive. Reattach when needed or use markForCheck for targeted updates.

  3. Expensive operations in bound functions — Template bindings like {{ expensiveMethod() }} run on every change detection cycle. Cache the result or use pipes.

  4. Forgetting to call markForCheck with observables — When using OnPush and subscribing to observables manually, call markForCheck in the subscribe callback.

  5. Modifying the component tree during change detection — Adding or removing components inside hooks like ngAfterViewChecked causes "ExpressionChangedAfterItHasBeenChecked" errors.

Practice Questions

  1. What triggers Angular's change detection? Async operations: user events, timers, HTTP requests, promise resolutions. Zone.js intercepts these and triggers a check cycle.

  2. How does OnPush differ from Default strategy? Default checks every component on every cycle. OnPush only checks when inputs change, events fire, or observables emit in the template.

  3. What does markForCheck() do? It flags the component and its ancestors to be checked on the next change detection cycle, even with OnPush.

  4. When would you use detach()? For static content that never changes, to skip change detection entirely for better performance.

  5. What is the ExpressionChangedAfterItHasBeenCheckedError? It occurs when a bound expression's value changes after Angular finished checking, often from modifying state in lifecycle hooks.

Challenge

Build a RealtimeFeedComponent that displays live data updates using OnPush. Use a service that pushes updates via a Subject. Subscribe in the component and call markForCheck on each new value. Add a pause/resume button that detaches and reattaches change detection.

FAQ

Does OnPush affect event handlers?

No, events from the component itself always trigger change detection for that component regardless of strategy.

How many change detection cycles does Angular run?

Usually one per async operation. In development mode, Angular runs an extra cycle to detect ExpressionChangedAfterItHasBeenChecked errors.

Can I disable zone.js entirely?

Yes, since Angular 16 you can use provideNoopZone() for zoneless change detection with signals.

What is the relationship between Zone.js and NgZone?

Zone.js provides the interception mechanism. NgZone is an Angular service that wraps Zone.js and provides the onMicrotaskEmpty event.

Does Angular check all components simultaneously?

No, change detection is synchronous and single-threaded. Angular walks the tree from root to leaves.

Mini Project

Build a StockTickerComponent that displays real-time stock prices. Use OnPush strategy and a service that emits price updates every second. Subscribe outside Angular's zone for performance, then re-enter the zone only when the price changes by more than 1%. Use markForCheck to update the UI. Compare the performance with and without OnPush by showing a frame counter.

What's Next

Continue with zone.js and signals for modern Angular:

Angular Zone.js, Angular Signals, Angular Standalone

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro