Introduction

Welcome to Chapter 14! In this chapter, we’re diving deep into one of Angular’s core mechanisms: change detection. This is how Angular knows when your application’s data has changed and, crucially, when to update the user interface to reflect those changes. While Angular handles much of this automatically, understanding its inner workings is vital for building high-performance, responsive applications, especially as they grow in complexity.

We’ll uncover why efficient change detection isn’t just a “nice-to-have” but a “must-have” for a smooth user experience. We’ll compare Angular’s default strategy with the powerful OnPush strategy, learn about the critical role of immutability, and explore tools like trackBy, the async pipe, and ChangeDetectorRef to fine-tune performance. By the end of this chapter, you’ll have the knowledge to diagnose and solve common performance bottlenecks related to UI updates, making your Angular applications truly fly.

Before we begin, ensure you’re comfortable with basic Angular component structure, data binding (input/output properties), and the use of services. These foundational concepts will help you grasp how change detection interacts with your component hierarchy.

Core Concepts: Understanding How Angular Stays Updated

Angular applications are dynamic. Data flows, user interactions occur, and asynchronous operations (like fetching data from an API) complete. Angular needs a way to detect these changes in your application’s data model and then efficiently update the parts of the DOM (Document Object Model) that have been affected. This entire process is called change detection.

What is Change Detection?

At its heart, change detection is Angular’s mechanism to synchronize your application’s data state with its UI. When something might have changed (e.g., a button click, an HTTP response, a setTimeout completing), Angular kicks off a cycle to check for differences. If it finds any, it updates the corresponding parts of the UI.

Think of it like a diligent librarian who needs to make sure the library’s catalog (your data model) always matches the physical books on the shelves (your UI).

The Default Change Detection Strategy (ChangeDetectionStrategy.Default)

By default, every Angular component uses the ChangeDetectionStrategy.Default. This strategy is robust and ensures everything just works out of the box.

How it works: Whenever an event occurs that might change data (like a user clicking a button, an HTTP request returning, or a timer firing), Angular performs a “dirty check” on all components in the component tree, from the root down to the deepest leaves. It compares the current value of every bound property in every component with its previous value. If a difference is found, it re-renders the associated part of the DOM.

Analogy: Our default librarian is incredibly thorough. Every time any book is touched, or a new request comes in, they walk through every single aisle, checking every single book to ensure it’s in the right place and that the catalog matches. While this guarantees accuracy, it can be slow if you have a massive library (a large application with many components).

When it’s fine: For smaller applications or components with limited data bindings, the default strategy is perfectly adequate. It requires no special configuration and ensures your UI is always up-to-date.

When it’s not: In large, complex applications with many components and frequent data updates, checking every single component in every change detection cycle can become a significant performance bottleneck, leading to a sluggish user interface.

Let’s visualize the difference between the default and a more optimized strategy:

flowchart TD subgraph Default_Change_Detection["Default Change Detection"] A[Event Triggered] --> B{Change Detection Cycle} B --> C[Check All Components] C --> D[Re render UI Changes] D --> E[Cycle Complete] end subgraph OnPush_Change_Detection["OnPush Change Detection"] F[Event Triggered] --> G{Change Detection Cycle} G --> H[Check Component Strategy] H --> I{Is OnPush} I --->|No| C I --->|Yes| J{Input Changed} J --->|Yes| K[Check Component and Children] J --->|No| L[Skip Component and Children] K --> M[Re render UI Changes] M --> E L --> E end

Introducing OnPush Change Detection (ChangeDetectionStrategy.OnPush)

This is where performance optimization truly begins! The OnPush strategy tells Angular: “Hey, only check this component (and its children) if there’s a good reason to believe something I care about has changed.”

An OnPush component only triggers change detection when one of the following conditions is met:

  1. Input properties change: A new reference to an input property is provided from its parent component. This is critical: Angular compares references, not deep values.
  2. An event originates from the component: An event listener within the component’s template (e.g., a button click) fires.
  3. An observable in the template emits a new value: If you’re using the async pipe with an observable in your template, a new emission will trigger a check.
  4. ChangeDetectorRef.detectChanges() or markForCheck() is explicitly called: You manually tell Angular to check this component.

Analogy: Our OnPush librarian is much smarter. They only check a specific shelf if:

  • A new, completely different book is swapped into a slot (input reference changed).
  • Someone explicitly requests a book from that shelf (event originated here).
  • The automated “new arrivals” feed for that section shows a new book (async pipe emits).
  • The librarian for that section personally decides it’s time for a quick check (markForCheck()).

This targeted checking dramatically reduces the number of components Angular has to inspect in each cycle, leading to significant performance gains.

The Power of Immutability

For OnPush to work effectively, immutability is not just a best practice; it’s a requirement for input properties.

What it means: An immutable object or array cannot be changed after it’s created. If you want to modify it, you must create a new object or array with the desired changes.

Why it’s essential for OnPush: As mentioned, OnPush components only trigger change detection if an input property’s reference changes. If you mutate an object (e.g., product.price = 100) that’s passed as an input, the object’s reference remains the same, even though its internal data has changed. An OnPush component won’t detect this mutation and therefore won’t update its UI.

By creating a new object (e.g., this.product = { ...this.product, price: 100 }), you provide a new reference, signaling to the OnPush component that its input has genuinely changed.

How to achieve immutability:

  • Spread operator (...): The most common and idiomatic way in JavaScript/TypeScript.
    const originalObject = { id: 1, name: 'Widget' };
    const updatedObject = { ...originalObject, name: 'Super Widget' }; // New object
    
  • Immutable.js or Immer: Libraries that enforce immutability, though often overkill for many Angular apps.
  • Object.freeze(): Prevents an object from being modified, but doesn’t create a new one. Useful for truly constant data.

trackBy with *ngFor

When displaying lists of data using *ngFor, especially large lists, trackBy is a performance hero.

The Problem: Without trackBy, if you update a list (e.g., add, remove, or reorder items), Angular’s default behavior for *ngFor is to tear down and re-render all the DOM elements associated with the list. This can be very inefficient and cause visual flickering.

The Solution: The trackBy function provides a unique identifier for each item in the list. When the list changes, Angular can use this identifier to determine exactly which items have been added, removed, or moved. Instead of re-rendering everything, it only performs the minimal necessary DOM manipulations.

// In your component
trackById(index: number, item: any): number {
  return item.id; // Assuming each item has a unique 'id' property
}

// In your template
<div *ngFor="let item of items; trackBy: trackById">
  {{ item.name }}
</div>

This ensures that if an item’s data changes but its id remains the same, Angular knows it’s the same item and only updates its properties, not the entire element.

The async Pipe: A Performance Hero for Observables

The async pipe (| async) is a powerful tool for working with observables (and promises) directly in your templates. It’s particularly beneficial when combined with OnPush change detection.

What it does:

  1. It subscribes to an Observable (or Promise) and automatically unwraps the latest emitted value.
  2. When a new value is emitted, it automatically marks the component for change detection (if OnPush is active).
  3. Crucially, it automatically unsubscribes when the component is destroyed, preventing memory leaks.

Why it’s great for OnPush: By using the async pipe, you delegate the subscription management and change detection triggering to Angular. This means you don’t need to manually subscribe/unsubscribe in ngOnInit/ngOnDestroy or call markForCheck(). When the observable emits, the async pipe tells Angular that a relevant input has effectively changed, triggering the OnPush component to check itself.

// In your component
products$: Observable<Product[]>;

constructor(private productService: ProductService) {
  this.products$ = this.productService.getProducts();
}

// In your template (with OnPush component)
<div *ngFor="let product of products$ | async">
  {{ product.name }}
</div>

ChangeDetectorRef for Fine-Grained Control

Even with OnPush, there might be scenarios where you need to explicitly tell Angular to run change detection for a component. This is where ChangeDetectorRef comes in.

You inject ChangeDetectorRef into your component:

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

@Component({
  selector: 'app-my-component',
  templateUrl: './my-component.component.html',
  changeDetection: ChangeDetectionStrategy.OnPush,
  standalone: true
})
export class MyComponent {
  internalData: string = 'Initial';

  constructor(private cdr: ChangeDetectorRef) {}

  updateInternalData() {
    this.internalData = 'Updated at ' + new Date().toLocaleTimeString();
    // Without this, the UI won't update because internalData is not an @Input
    this.cdr.markForCheck();
  }
}

Key methods of ChangeDetectorRef:

  • markForCheck(): This is the most common method. It marks the component for change detection during the next cycle. Angular will then check this component and its ancestors from the root down to this component. It’s useful when an internal state changes (not via @Input or async pipe) and you need the UI to reflect it.
  • detectChanges(): Triggers a change detection cycle immediately for the current component and all its children. This is a more aggressive approach and should be used sparingly, as it can negate the benefits of OnPush if overused.
  • detach(): Detaches the change detector from the component tree. This component (and its children) will never be checked again until reattached. This is an extreme optimization for components that are truly static after initial render.
  • reattach(): Reattaches a previously detached change detector.

Briefly on Signals (Angular v17+)

As of Angular v17 (and certainly by 2026), Signals are a fundamental part of Angular’s reactivity system, offering an even more granular way to manage state and trigger updates. While OnPush focuses on component-level checks, Signals provide reactivity at the individual value level.

When you use Signals, Angular can know precisely which parts of the template depend on which Signal values. This allows for extremely fine-grained updates, often without the need for markForCheck() or complex OnPush configurations, as components using Signals will automatically update only the specific parts of the DOM that depend on changed Signal values.

Signals complement OnPush perfectly, as OnPush components can consume Signals, and Angular’s runtime can optimize updates even further. For deep dives into Signals, refer to the official Angular documentation. For this chapter, we’ll focus on OnPush as the primary performance strategy for components not yet fully migrated to a Signal-based approach or for scenarios where OnPush patterns are still highly relevant.

Step-by-Step Implementation: Optimizing a Product List

Let’s build a simple product list application and progressively apply change detection optimizations.

First, let’s create our components. We’ll use a ProductListComponent (parent) and ProductCardComponent (child).

Project Setup

Ensure you have the latest Angular CLI installed (we’ll assume Angular v18+ for 2026).

npm install -g @angular/cli@next # Or @latest, depending on 2026-02-11 stable
ng new angular-cd-perf --standalone --strict --style=css --routing=false
cd angular-cd-perf

Now, generate the components:

ng generate component components/product-list --standalone
ng generate component components/product-card --standalone

Step 1: Default Strategy (Baseline)

Let’s start with the default change detection strategy to establish a baseline.

src/app/components/product-card/product-card.component.ts

import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common'; // Needed for *ngIf, etc.

export interface Product {
  id: number;
  name: string;
  price: number;
  lastUpdated: string;
}

@Component({
  selector: 'app-product-card',
  standalone: true,
  imports: [CommonModule],
  template: `
    <div class="product-card">
      <h3>{{ product.name }} (ID: {{ product.id }})</h3>
      <p>Price: \${{ product.price | number:'1.2-2' }}</p>
      <p><small>Last Updated: {{ product.lastUpdated }}</small></p>
      <button (click)="logChangeDetection()">Log CD</button>
    </div>
  `,
  styles: `
    .product-card {
      border: 1px solid #ccc;
      padding: 15px;
      margin: 10px;
      border-radius: 8px;
      background-color: #f9f9f9;
      box-shadow: 0 2px 4px rgba(0,0,0,0.1);
    }
  `
})
export class ProductCardComponent {
  @Input() product!: Product;

  constructor() {
    console.log(`ProductCardComponent created for ID: ${this.product?.id}`);
  }

  ngOnChanges(changes: any): void {
    console.log(`ProductCardComponent - ngOnChanges for ID: ${this.product?.id}`, changes);
  }

  ngDoCheck(): void {
    console.log(`ProductCardComponent - ngDoCheck for ID: ${this.product?.id}`);
  }

  logChangeDetection() {
    console.log(`ProductCardComponent - Button Clicked for ID: ${this.product?.id}`);
  }
}

Explanation:

  • We define a Product interface.
  • The ProductCardComponent takes a product as an @Input.
  • ngOnChanges and ngDoCheck lifecycle hooks are added to log when Angular performs checks. ngDoCheck is called during every change detection cycle, regardless of input changes, making it ideal for observing CD.
  • A button is added to demonstrate an internal event.

src/app/components/product-list/product-list.component.ts

import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Product, ProductCardComponent } from '../product-card/product-card.component';

@Component({
  selector: 'app-product-list',
  standalone: true,
  imports: [CommonModule, ProductCardComponent],
  template: `
    <h2>Product List (Default CD)</h2>
    <button (click)="updateRandomProductPrice()">Update Random Product Price</button>
    <button (click)="addProduct()">Add Product</button>
    <button (click)="triggerParentCD()">Trigger Parent CD</button>

    <div class="product-grid">
      <app-product-card *ngFor="let product of products" [product]="product"></app-product-card>
    </div>
  `,
  styles: `
    .product-grid {
      display: grid;
      grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
      gap: 20px;
      padding: 20px;
    }
    button {
      padding: 10px 15px;
      margin: 5px;
      background-color: #007bff;
      color: white;
      border: none;
      border-radius: 5px;
      cursor: pointer;
    }
    button:hover {
      background-color: #0056b3;
    }
  `
})
export class ProductListComponent implements OnInit {
  products: Product[] = [];
  private nextProductId = 1;

  ngOnInit(): void {
    this.products = [
      { id: this.nextProductId++, name: 'Laptop', price: 1200, lastUpdated: new Date().toLocaleTimeString() },
      { id: this.nextProductId++, name: 'Mouse', price: 25, lastUpdated: new Date().toLocaleTimeString() },
      { id: this.nextProductId++, name: 'Keyboard', price: 75, lastUpdated: new Date().toLocaleTimeString() },
    ];
  }

  updateRandomProductPrice() {
    const randomIndex = Math.floor(Math.random() * this.products.length);
    const productToUpdate = this.products[randomIndex];

    // !!! IMPORTANT: Mutating the object directly
    productToUpdate.price = +(productToUpdate.price * (1 + (Math.random() * 0.1 - 0.05))).toFixed(2);
    productToUpdate.lastUpdated = new Date().toLocaleTimeString();
    console.log(`Updated product ID ${productToUpdate.id} (MUTATION)`);
  }

  addProduct() {
    this.products.push({
      id: this.nextProductId++,
      name: `New Gadget ${this.nextProductId}`,
      price: Math.floor(Math.random() * 500) + 50,
      lastUpdated: new Date().toLocaleTimeString()
    });
    console.log('Added new product (MUTATION)');
  }

  triggerParentCD() {
    // This method does nothing, but clicking it will still trigger CD for all components
    console.log('Parent CD triggered (no data change)');
  }
}

Explanation:

  • ProductListComponent initializes a list of products.
  • updateRandomProductPrice() mutates an existing product object directly.
  • addProduct() mutates the products array by pushing a new item.
  • triggerParentCD() is a dummy button to show that any event in the parent triggers CD for all children by default.
  • The ProductCardComponent is used in an *ngFor loop.

src/main.ts

import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { ProductListComponent } from './app/components/product-list/product-list.component'; // Import it

bootstrapApplication(AppComponent, {
  providers: []
}).catch(err => console.error(err));

src/app/app.component.ts

import { Component } from '@angular/core';
import { ProductListComponent } from './components/product-list/product-list.component';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [ProductListComponent], // Make sure ProductListComponent is imported
  template: `<app-product-list></app-product-list>`,
})
export class AppComponent {
  title = 'angular-cd-perf';
}

Now, run ng serve and open your browser’s console.

  • Observe ngDoCheck being logged for every ProductCardComponent whenever you click any button (even “Trigger Parent CD”).
  • When you click “Update Random Product Price”, the price in the UI updates, and all ProductCardComponents log ngDoCheck.
  • When you click “Add Product”, all ProductCardComponents log ngDoCheck, and the new product appears.

This demonstrates the “overly thorough librarian” behavior of the default change detection.

Step 2: Introducing OnPush

Let’s tell our ProductCardComponent to be smarter.

Modify src/app/components/product-card/product-card.component.ts Add changeDetection: ChangeDetectionStrategy.OnPush to the @Component decorator.

import { Component, Input, ChangeDetectionStrategy, OnChanges, DoCheck, SimpleChanges } from '@angular/core'; // Add OnChanges, DoCheck, SimpleChanges
import { CommonModule } from '@angular/common';

export interface Product {
  id: number;
  name: string;
  price: number;
  lastUpdated: string;
}

@Component({
  selector: 'app-product-card',
  standalone: true,
  imports: [CommonModule],
  template: `
    <div class="product-card">
      <h3>{{ product.name }} (ID: {{ product.id }})</h3>
      <p>Price: \${{ product.price | number:'1.2-2' }}</p>
      <p><small>Last Updated: {{ product.lastUpdated }}</small></p>
      <button (click)="logChangeDetection()">Log CD</button>
    </div>
  `,
  styles: `
    .product-card {
      border: 1px solid #ccc;
      padding: 15px;
      margin: 10px;
      border-radius: 8px;
      background-color: #f9f9f9;
      box-shadow: 0 2px 4px rgba(0,0,0,0.1);
    }
  `,
  changeDetection: ChangeDetectionStrategy.OnPush // <<< ADD THIS LINE
})
export class ProductCardComponent implements OnChanges, DoCheck { // <<< IMPLEMENT INTERFACES
  @Input() product!: Product;

  constructor() {
    console.log(`ProductCardComponent created for ID: ${this.product?.id}`);
  }

  ngOnChanges(changes: SimpleChanges): void { // <<< Use SimpleChanges type
    console.log(`ProductCardComponent - ngOnChanges for ID: ${this.product?.id}`, changes);
  }

  ngDoCheck(): void {
    console.log(`ProductCardComponent - ngDoCheck for ID: ${this.product?.id}`);
  }

  logChangeDetection() {
    console.log(`ProductCardComponent - Button Clicked for ID: ${this.product?.id}`);
  }
}

Observe:

  • Reload the application.
  • Click “Trigger Parent CD”. Notice that ngDoCheck is no longer logged for ProductCardComponents! This is because their inputs haven’t changed, and no event originated from them.
  • Click “Update Random Product Price”. The price changes in the data, but the UI for that product does not update! This is because the parent component mutated the product object; it didn’t provide a new reference. OnPush correctly saw that the product input reference didn’t change and skipped checking.
  • Click “Add Product”. The new product is added, and all existing ProductCardComponents still don’t log ngDoCheck (their product inputs didn’t change reference), but the ProductCardComponent for the new product is created and initialized.

This perfectly illustrates why immutability is crucial with OnPush.

Step 3: Embracing Immutability

Now, let’s fix the update issue by ensuring we provide new object references.

Modify src/app/components/product-list/product-list.component.ts

// ... (imports and component decorator remain the same)
export class ProductListComponent implements OnInit {
  products: Product[] = [];
  private nextProductId = 1;

  ngOnInit(): void {
    this.products = [
      { id: this.nextProductId++, name: 'Laptop', price: 1200, lastUpdated: new Date().toLocaleTimeString() },
      { id: this.nextProductId++, name: 'Mouse', price: 25, lastUpdated: new Date().toLocaleTimeString() },
      { id: this.nextProductId++, name: 'Keyboard', price: 75, lastUpdated: new Date().toLocaleTimeString() },
    ];
  }

  updateRandomProductPrice() {
    const randomIndex = Math.floor(Math.random() * this.products.length);
    const productToUpdate = this.products[randomIndex];

    // <<< IMPORTANT: Create a NEW product object (immutable update)
    const updatedProduct = {
      ...productToUpdate, // Copy existing properties
      price: +(productToUpdate.price * (1 + (Math.random() * 0.1 - 0.05))).toFixed(2),
      lastUpdated: new Date().toLocaleTimeString()
    };

    // Create a NEW array with the updated product
    this.products = this.products.map(p => p.id === updatedProduct.id ? updatedProduct : p);
    console.log(`Updated product ID ${updatedProduct.id} (IMMUTABLE UPDATE)`);
  }

  addProduct() {
    // <<< IMPORTANT: Create a NEW array with the new product (immutable update)
    const newProduct = {
      id: this.nextProductId++,
      name: `New Gadget ${this.nextProductId}`,
      price: Math.floor(Math.random() * 500) + 50,
      lastUpdated: new Date().toLocaleTimeString()
    };
    this.products = [...this.products, newProduct]; // Create new array
    console.log('Added new product (IMMUTABLE UPDATE)');
  }

  triggerParentCD() {
    console.log('Parent CD triggered (no data change)');
  }
}

Observe:

  • Reload the application.
  • Click “Update Random Product Price”. Now, the specific ProductCardComponent for the updated product does update its UI, and you’ll see ngOnChanges and ngDoCheck logged only for that specific card. The other cards remain untouched by change detection.
  • Click “Add Product”. The new product appears, and you’ll see ngOnChanges and ngDoCheck logged for all ProductCardComponents. Wait, why all? Because we’re providing a new array reference to the *ngFor, which doesn’t know which items changed without trackBy! This brings us to the next step.

Step 4: trackBy for Lists

Let’s optimize the *ngFor in ProductListComponent to work efficiently with immutable array updates.

Modify src/app/components/product-list/product-list.component.ts

// ... (imports and component decorator remain the same)
export class ProductListComponent implements OnInit {
  products: Product[] = [];
  private nextProductId = 1;

  ngOnInit(): void {
    this.products = [
      { id: this.nextProductId++, name: 'Laptop', price: 1200, lastUpdated: new Date().toLocaleTimeString() },
      { id: this.nextProductId++, name: 'Mouse', price: 25, lastUpdated: new Date().toLocaleTimeString() },
      { id: this.nextProductId++, name: 'Keyboard', price: 75, lastUpdated: new Date().toLocaleTimeString() },
    ];
  }

  // <<< ADD THIS METHOD
  trackByProductId(index: number, product: Product): number {
    return product.id;
  }

  updateRandomProductPrice() {
    // ... (same as before)
    const randomIndex = Math.floor(Math.random() * this.products.length);
    const productToUpdate = this.products[randomIndex];
    const updatedProduct = {
      ...productToUpdate,
      price: +(productToUpdate.price * (1 + (Math.random() * 0.1 - 0.05))).toFixed(2),
      lastUpdated: new Date().toLocaleTimeString()
    };
    this.products = this.products.map(p => p.id === updatedProduct.id ? updatedProduct : p);
    console.log(`Updated product ID ${updatedProduct.id} (IMMUTABLE UPDATE)`);
  }

  addProduct() {
    // ... (same as before)
    const newProduct = {
      id: this.nextProductId++,
      name: `New Gadget ${this.nextProductId}`,
      price: Math.floor(Math.random() * 500) + 50,
      lastUpdated: new Date().toLocaleTimeString()
    };
    this.products = [...this.products, newProduct];
    console.log('Added new product (IMMUTABLE UPDATE)');
  }

  triggerParentCD() {
    console.log('Parent CD triggered (no data change)');
  }
}

Modify src/app/components/product-list/product-list.component.html Add trackBy to the *ngFor loop:

<!-- ... -->
<div class="product-grid">
  <app-product-card *ngFor="let product of products; trackBy: trackByProductId" [product]="product"></app-product-card>
</div>

Explanation:

  • We’ve added a trackByProductId method that returns the product.id.
  • We’ve applied this method to the *ngFor directive.

Observe:

  • Reload the application.
  • Click “Add Product”. Now, only the new ProductCardComponent will log ngOnChanges/ngDoCheck (as it’s being created). The existing cards remain untouched by change detection! Angular smartly identified that only a new item was added, not that the entire list changed.
  • Click “Update Random Product Price”. Still only the affected card logs ngOnChanges/ngDoCheck.

This is the combined power of OnPush and trackBy for efficient list rendering!

Step 5: async Pipe and Observables

Let’s integrate an observable data source and use the async pipe. This will also demonstrate how OnPush components automatically update when an observable they’re bound to emits a new value.

First, create a simple service to simulate API calls.

ng generate service services/product

src/app/services/product.service.ts

import { Injectable } from '@angular/core';
import { Observable, of, BehaviorSubject } from 'rxjs';
import { Product } from '../components/product-card/product-card.component';
import { delay, map } from 'rxjs/operators';

@Injectable({
  providedIn: 'root'
})
export class ProductService {
  private productsSubject = new BehaviorSubject<Product[]>([
    { id: 1, name: 'Laptop', price: 1200, lastUpdated: new Date().toLocaleTimeString() },
    { id: 2, name: 'Mouse', price: 25, lastUpdated: new Date().toLocaleTimeString() },
    { id: 3, name: 'Keyboard', price: 75, lastUpdated: new Date().toLocaleTimeString() },
  ]);
  private nextProductId = 4;

  constructor() { }

  getProducts(): Observable<Product[]> {
    return this.productsSubject.asObservable().pipe(delay(100)); // Simulate network delay
  }

  updateProductPrice(id: number): Observable<Product[]> {
    const currentProducts = this.productsSubject.getValue();
    const updatedProducts = currentProducts.map(p => {
      if (p.id === id) {
        return {
          ...p,
          price: +(p.price * (1 + (Math.random() * 0.1 - 0.05))).toFixed(2),
          lastUpdated: new Date().toLocaleTimeString()
        };
      }
      return p;
    });
    this.productsSubject.next(updatedProducts); // Emit new array reference
    return of(updatedProducts).pipe(delay(100));
  }

  addProduct(name: string, price: number): Observable<Product[]> {
    const currentProducts = this.productsSubject.getValue();
    const newProduct: Product = {
      id: this.nextProductId++,
      name,
      price,
      lastUpdated: new Date().toLocaleTimeString()
    };
    this.productsSubject.next([...currentProducts, newProduct]); // Emit new array reference
    return of([...currentProducts, newProduct]).pipe(delay(100));
  }
}

Explanation:

  • ProductService now uses a BehaviorSubject to hold and emit product data. This simulates a reactive data source.
  • getProducts, updateProductPrice, and addProduct methods all return Observable<Product[]> and ensure new array references are emitted.

Modify src/app/components/product-list/product-list.component.ts Refactor to use the ProductService and async pipe.

import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Product, ProductCardComponent } from '../product-card/product-card.component';
import { ProductService } from '../../services/product.service'; // <<< IMPORT SERVICE
import { Observable } from 'rxjs'; // <<< IMPORT Observable

@Component({
  selector: 'app-product-list',
  standalone: true,
  imports: [CommonModule, ProductCardComponent],
  template: `
    <h2>Product List (OnPush + Async Pipe)</h2>
    <button (click)="updateRandomProductPrice()">Update Random Product Price</button>
    <button (click)="addProduct()">Add Product</button>
    <button (click)="triggerParentCD()">Trigger Parent CD</button>

    <div class="product-grid">
      <!-- <<< USE ASYNC PIPE HERE -->
      <app-product-card *ngFor="let product of products$ | async; trackBy: trackByProductId" [product]="product"></app-product-card>
    </div>
  `,
  styles: `
    .product-grid {
      display: grid;
      grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
      gap: 20px;
      padding: 20px;
    }
    button {
      padding: 10px 15px;
      margin: 5px;
      background-color: #007bff;
      color: white;
      border: none;
      border-radius: 5px;
      cursor: pointer;
    }
    button:hover {
      background-color: #0056b3;
    }
  `
})
export class ProductListComponent implements OnInit {
  products$: Observable<Product[]>; // <<< Change to Observable
  private currentProducts: Product[] = []; // Keep a local copy for update logic

  constructor(private productService: ProductService) {
    this.products$ = this.productService.getProducts();
  }

  ngOnInit(): void {
    // Subscribe once to keep a local copy for deriving random updates
    this.products$.subscribe(products => this.currentProducts = products);
  }

  trackByProductId(index: number, product: Product): number {
    return product.id;
  }

  updateRandomProductPrice() {
    if (this.currentProducts.length === 0) return;
    const randomIndex = Math.floor(Math.random() * this.currentProducts.length);
    const productToUpdate = this.currentProducts[randomIndex];
    this.productService.updateProductPrice(productToUpdate.id).subscribe();
    console.log(`Requested update for product ID ${productToUpdate.id}`);
  }

  addProduct() {
    this.productService.addProduct(`New Gadget ${Math.floor(Math.random() * 1000)}`, Math.floor(Math.random() * 500) + 50).subscribe();
    console.log('Requested add new product');
  }

  triggerParentCD() {
    console.log('Parent CD triggered (no data change)');
  }
}

Explanation:

  • products$ is now an Observable<Product[]>.
  • The *ngFor in the template uses products$ | async.
  • The ngOnInit now subscribes to products$ to keep a local currentProducts array for the updateRandomProductPrice and addProduct methods, as they need to select a product to update or generate a new one based on existing data. In a real app, you might use withLatestFrom or other RxJS operators to avoid this side-effect subscription.
  • The update/add actions now call methods on the ProductService, which then emits new values through its BehaviorSubject.

Observe:

  • Reload the application.
  • Notice how ProductCardComponents still only log ngOnChanges/ngDoCheck when their specific product is updated or a new one is added.
  • The async pipe handles subscription and unsubscription automatically, and efficiently triggers change detection for OnPush components.

Step 6: ChangeDetectorRef.markForCheck()

Let’s add an internal counter to our ProductCardComponent that isn’t an input and isn’t updated by an observable. We’ll use markForCheck() to ensure its UI updates.

Modify src/app/components/product-card/product-card.component.ts

import { Component, Input, ChangeDetectionStrategy, OnChanges, DoCheck, SimpleChanges, ChangeDetectorRef } from '@angular/core'; // <<< IMPORT ChangeDetectorRef
import { CommonModule } from '@angular/common';

export interface Product {
  id: number;
  name: string;
  price: number;
  lastUpdated: string;
}

@Component({
  selector: 'app-product-card',
  standalone: true,
  imports: [CommonModule],
  template: `
    <div class="product-card">
      <h3>{{ product.name }} (ID: {{ product.id }})</h3>
      <p>Price: \${{ product.price | number:'1.2-2' }}</p>
      <p><small>Last Updated: {{ product.lastUpdated }}</small></p>
      <button (click)="logChangeDetection()">Log CD</button>
      <hr>
      <!-- <<< ADD INTERNAL COUNTER DISPLAY AND BUTTON -->
      <p>Internal Clicks: {{ internalClickCount }}</p>
      <button (click)="incrementInternalCounter()">Increment Internal Counter</button>
    </div>
  `,
  styles: `
    .product-card {
      border: 1px solid #ccc;
      padding: 15px;
      margin: 10px;
      border-radius: 8px;
      background-color: #f9f9f9;
      box-shadow: 0 2px 4px rgba(0,0,0,0.1);
    }
    button {
      margin-right: 5px;
    }
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProductCardComponent implements OnChanges, DoCheck {
  @Input() product!: Product;
  internalClickCount: number = 0; // <<< ADD INTERNAL PROPERTY

  constructor(private cdr: ChangeDetectorRef) { // <<< INJECT ChangeDetectorRef
    console.log(`ProductCardComponent created for ID: ${this.product?.id}`);
  }

  ngOnChanges(changes: SimpleChanges): void {
    console.log(`ProductCardComponent - ngOnChanges for ID: ${this.product?.id}`, changes);
  }

  ngDoCheck(): void {
    console.log(`ProductCardComponent - ngDoCheck for ID: ${this.product?.id}`);
  }

  logChangeDetection() {
    console.log(`ProductCardComponent - Button Clicked for ID: ${this.product?.id}`);
  }

  // <<< ADD METHOD TO INCREMENT INTERNAL COUNTER
  incrementInternalCounter() {
    this.internalClickCount++;
    console.log(`Internal counter for ID ${this.product.id}: ${this.internalClickCount}`);
    // If we didn't call markForCheck(), the UI wouldn't update because
    // this change is internal and not an @Input or async pipe event.
    this.cdr.markForCheck(); // <<< EXPLICITLY MARK FOR CHECK
  }
}

Explanation:

  • We’ve added an internalClickCount property and a button to increment it.
  • The ChangeDetectorRef is injected into the constructor.
  • After internalClickCount is updated, this.cdr.markForCheck() is called. This tells Angular: “Even though my inputs haven’t changed, something inside me has, please include me in the next change detection cycle.”

Observe:

  • Reload the application.
  • Click the “Increment Internal Counter” button on any product card.
  • You’ll see the internalClickCount in the UI update, and ngDoCheck will be logged only for that specific ProductCardComponent. Other cards remain untouched.
  • If you comment out this.cdr.markForCheck(), the internalClickCount in the UI will not update, even though the internalClickCount property in the component’s class is changing.

This demonstrates how to use markForCheck() for precise control over OnPush components when internal state changes.

Mini-Challenge: User Status Component with OnPush and ChangeDetectorRef

Challenge: Create a standalone UserStatusComponent that displays a user’s online status (e.g., “Online”, “Away”, “Offline”). This component should use OnPush change detection. Implement a button within the component that cycles through these statuses. The UI should only update when the status changes and ChangeDetectorRef.markForCheck() is explicitly called.

Hint:

  1. Generate a new standalone component: ng g c components/user-status --standalone.
  2. Add changeDetection: ChangeDetectionStrategy.OnPush to its @Component decorator.
  3. Define an internal property for status (e.g., currentStatus: 'online' | 'away' | 'offline' = 'offline';).
  4. Add a button that calls a method to update currentStatus.
  5. In that method, after updating currentStatus, inject ChangeDetectorRef and call this.cdr.markForCheck().
  6. Display currentStatus in the template.

What to observe/learn: Verify that clicking the status change button only updates the UI for that specific UserStatusComponent and that the update doesn’t happen if markForCheck() is omitted, reinforcing the concept of manual signaling for internal state changes in OnPush components.

Common Pitfalls & Troubleshooting

  1. Mutating Objects with OnPush:

    • Problem: You set changeDetection: ChangeDetectionStrategy.OnPush on a component, but when its @Input object’s properties change, the UI doesn’t update.
    • Reason: Angular’s OnPush strategy only checks if the reference of the input object has changed. If you mutate the object directly (e.g., user.name = 'New Name'), the reference remains the same, and OnPush skips checking the component.
    • Debugging: Use console.log in ngOnChanges to see if changes actually contains the expected input change. Inspect the object reference in the parent and child components using browser dev tools.
    • Solution: Always use immutable updates for objects and arrays passed to OnPush components (e.g., this.user = { ...this.user, name: 'New Name' }).
  2. Overusing detectChanges():

    • Problem: You’ve implemented OnPush but find yourself calling this.cdr.detectChanges() frequently in many places. This can lead to performance issues similar to the default strategy.
    • Reason: detectChanges() forces a change detection cycle for the current component and all its children, regardless of their OnPush status. If called excessively, it negates the benefits of OnPush.
    • Debugging: Use Angular DevTools (a browser extension) to profile change detection cycles. Look for components that are being checked unnecessarily or too often.
    • Solution: Prefer this.cdr.markForCheck() when an internal state change needs to trigger an update. This marks the component for checking during the next global CD cycle, allowing Angular to optimize the overall flow. Even better, try to structure your components to rely on async pipes for observable data and input reference changes for most updates.
  3. Forgetting trackBy for large lists:

    • Problem: You have a long list rendered with *ngFor. When you add, remove, or reorder items in the underlying array (even with immutable updates), the UI flashes, or performance degrades noticeably.
    • Reason: Without trackBy, Angular doesn’t have a way to uniquely identify each item. When the array reference changes, it assumes all items might be new, and it re-renders all the DOM elements, even if most items are the same.
    • Debugging: Use your browser’s developer tools (Elements tab) to observe DOM manipulation. When the list updates, if many elements are being added/removed/replaced instead of just updated, trackBy is likely missing.
    • Solution: Always provide a trackBy function for *ngFor loops, especially with dynamic lists, returning a unique identifier for each item (e.g., item.id).

Summary

Congratulations! You’ve navigated the complexities of Angular’s change detection, a critical aspect of building performant applications. Here are the key takeaways from this chapter:

  • Change Detection Mechanism: Angular automatically detects data changes and updates the UI. The default strategy checks all components on every potential change, which can be inefficient for large apps.
  • OnPush Strategy: This is your go-to for performance. It tells Angular to only check a component when its inputs change (by reference), an event originates from it, an async pipe emits, or you explicitly mark it for check.
  • Immutability is Key: For OnPush to work, always update objects and arrays by creating new instances (e.g., using the spread operator ...) rather than mutating them directly. This allows Angular to detect input reference changes.
  • trackBy for *ngFor: Essential for optimizing list rendering. It helps Angular efficiently identify and update only the changed items in a list, preventing unnecessary DOM re-renders.
  • async Pipe: A powerful and idiomatic way to handle observables in templates. It automatically subscribes, unwraps values, triggers change detection for OnPush components, and handles unsubscription, preventing memory leaks.
  • ChangeDetectorRef: Provides fine-grained control over change detection. Use markForCheck() to tell an OnPush component to check itself during the next cycle when internal state changes without an input update. detectChanges(), detach(), and reattach() offer more aggressive control but should be used cautiously.
  • Signals (Angular v17+): While OnPush is powerful, Signals represent a modern, even more granular reactivity primitive in Angular, complementing OnPush by allowing updates at the individual value level, further simplifying performance optimization.

By mastering these strategies, you’re now equipped to build highly optimized and responsive Angular applications that provide a superior user experience.

In the next chapter, we’ll shift our focus to Global Error Handling, Structured Logging, Observability Integration, and User-Safe Messaging, ensuring your robust, performant applications are also resilient and user-friendly in the face of unexpected issues.

References


This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.