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:
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:
- 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.
- An event originates from the component: An event listener within the component’s template (e.g., a button click) fires.
- An observable in the template emits a new value: If you’re using the
asyncpipe with an observable in your template, a new emission will trigger a check. ChangeDetectorRef.detectChanges()ormarkForCheck()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:
- It subscribes to an
Observable(orPromise) and automatically unwraps the latest emitted value. - When a new value is emitted, it automatically marks the component for change detection (if
OnPushis active). - 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@Inputorasyncpipe) 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 ofOnPushif 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
Productinterface. - The
ProductCardComponenttakes aproductas an@Input. ngOnChangesandngDoChecklifecycle hooks are added to log when Angular performs checks.ngDoCheckis 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:
ProductListComponentinitializes a list of products.updateRandomProductPrice()mutates an existing product object directly.addProduct()mutates theproductsarray 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
ProductCardComponentis used in an*ngForloop.
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
ngDoCheckbeing logged for everyProductCardComponentwhenever you click any button (even “Trigger Parent CD”). - When you click “Update Random Product Price”, the price in the UI updates, and all
ProductCardComponents logngDoCheck. - When you click “Add Product”, all
ProductCardComponents logngDoCheck, 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
ngDoCheckis no longer logged forProductCardComponents! 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
productobject; it didn’t provide a new reference.OnPushcorrectly saw that theproductinput reference didn’t change and skipped checking. - Click “Add Product”. The new product is added, and all existing
ProductCardComponents still don’t logngDoCheck(theirproductinputs didn’t change reference), but theProductCardComponentfor 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
ProductCardComponentfor the updated product does update its UI, and you’ll seengOnChangesandngDoChecklogged only for that specific card. The other cards remain untouched by change detection. - Click “Add Product”. The new product appears, and you’ll see
ngOnChangesandngDoChecklogged for allProductCardComponents. Wait, why all? Because we’re providing a new array reference to the*ngFor, which doesn’t know which items changed withouttrackBy! 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
trackByProductIdmethod that returns theproduct.id. - We’ve applied this method to the
*ngFordirective.
Observe:
- Reload the application.
- Click “Add Product”. Now, only the new
ProductCardComponentwill logngOnChanges/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:
ProductServicenow uses aBehaviorSubjectto hold and emit product data. This simulates a reactive data source.getProducts,updateProductPrice, andaddProductmethods all returnObservable<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 anObservable<Product[]>.- The
*ngForin the template usesproducts$ | async. - The
ngOnInitnow subscribes toproducts$to keep a localcurrentProductsarray for theupdateRandomProductPriceandaddProductmethods, as they need to select a product to update or generate a new one based on existing data. In a real app, you might usewithLatestFromor 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 itsBehaviorSubject.
Observe:
- Reload the application.
- Notice how
ProductCardComponents still only logngOnChanges/ngDoCheckwhen their specific product is updated or a new one is added. - The
asyncpipe handles subscription and unsubscription automatically, and efficiently triggers change detection forOnPushcomponents.
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
internalClickCountproperty and a button to increment it. - The
ChangeDetectorRefis injected into the constructor. - After
internalClickCountis 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
internalClickCountin the UI update, andngDoCheckwill be logged only for that specificProductCardComponent. Other cards remain untouched. - If you comment out
this.cdr.markForCheck(), theinternalClickCountin the UI will not update, even though theinternalClickCountproperty 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:
- Generate a new standalone component:
ng g c components/user-status --standalone. - Add
changeDetection: ChangeDetectionStrategy.OnPushto its@Componentdecorator. - Define an internal property for
status(e.g.,currentStatus: 'online' | 'away' | 'offline' = 'offline';). - Add a button that calls a method to update
currentStatus. - In that method, after updating
currentStatus, injectChangeDetectorRefand callthis.cdr.markForCheck(). - Display
currentStatusin 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
Mutating Objects with
OnPush:- Problem: You set
changeDetection: ChangeDetectionStrategy.OnPushon a component, but when its@Inputobject’s properties change, the UI doesn’t update. - Reason: Angular’s
OnPushstrategy 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, andOnPushskips checking the component. - Debugging: Use
console.loginngOnChangesto see ifchangesactually 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
OnPushcomponents (e.g.,this.user = { ...this.user, name: 'New Name' }).
- Problem: You set
Overusing
detectChanges():- Problem: You’ve implemented
OnPushbut find yourself callingthis.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 theirOnPushstatus. If called excessively, it negates the benefits ofOnPush. - 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 onasyncpipes for observable data and input reference changes for most updates.
- Problem: You’ve implemented
Forgetting
trackByfor 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,
trackByis likely missing. - Solution: Always provide a
trackByfunction for*ngForloops, especially with dynamic lists, returning a unique identifier for each item (e.g.,item.id).
- Problem: You have a long list rendered with
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.
OnPushStrategy: 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, anasyncpipe emits, or you explicitly mark it for check.- Immutability is Key: For
OnPushto 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. trackByfor*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.asyncPipe: A powerful and idiomatic way to handle observables in templates. It automatically subscribes, unwraps values, triggers change detection forOnPushcomponents, and handles unsubscription, preventing memory leaks.ChangeDetectorRef: Provides fine-grained control over change detection. UsemarkForCheck()to tell anOnPushcomponent to check itself during the next cycle when internal state changes without an input update.detectChanges(),detach(), andreattach()offer more aggressive control but should be used cautiously.- Signals (Angular v17+): While
OnPushis powerful, Signals represent a modern, even more granular reactivity primitive in Angular, complementingOnPushby 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
- Angular Official Documentation: Change Detection
- Angular Official Documentation:
trackByin*ngFor - Angular Official Documentation:
asyncPipe - Angular Official Documentation:
ChangeDetectorRef - Angular Official Documentation: Signals
This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.