Change Detection — OnPush Strategy and Signal-Based Optimisation

Angular’s change detection determines when and how the UI should update in response to state changes. The default strategy checks every component on every browser event — fast enough for most applications but wasteful at scale. The OnPush strategy tells Angular to only check a component when its inputs change by reference, an event handler fires within the component, or an async pipe emits. Angular 18’s Signals integrate seamlessly with change detection — components that use signals automatically update when the signal value changes, using an OnPush-equivalent mechanism without explicit configuration.

Default vs OnPush Change Detection

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

// ── Default change detection (runs on every event everywhere) ─────────────
@Component({
  selector:   'app-post-card-default',
  standalone:  true,
  // Default: no changeDetection specified
  template: `<div>{{ post.title }}</div>`,
})
export class PostCardDefaultComponent {
  @Input() post!: PostSummaryDto;
  // Angular checks this component on every click, keypress, HTTP response, etc.
  // across the entire application — even if this post never changes
}

// ── OnPush change detection (checks only when inputs change or event fires) ──
@Component({
  selector:   'app-post-card-optimised',
  standalone:  true,
  changeDetection: ChangeDetectionStrategy.OnPush,   // ← opt-in optimisation
  template: `
    <article>
      <h2>{{ post.title }}</h2>
      <p>Views: {{ viewCount() }}</p>   <!-- Signal — auto-detected ──── -->
      <button (click)="onLike()">Like</button>
    </article>
  `,
})
export class PostCardOptimisedComponent {
  @Input({ required: true }) post!: PostSummaryDto;

  // Signal-based local state — works perfectly with OnPush
  viewCount = signal(0);
  private cd = inject(ChangeDetectorRef);

  onLike(): void {
    // Event handlers within the component trigger checking automatically
    this.viewCount.update(v => v + 1);   // signal update triggers re-render
  }

  // Called from outside (e.g., SignalR push) — must manually mark for check
  receiveExternalUpdate(newCount: number): void {
    this.viewCount.set(newCount);   // signal: automatically marks for check
    // Without signals, manual: this.cd.markForCheck();
  }
}

// ── When to use OnPush ─────────────────────────────────────────────────────
// ✅ Leaf components that render data (PostCard, UserAvatar, ProductPrice)
// ✅ Components with many siblings (post list items, table rows)
// ✅ Components whose inputs rarely change
// ⚠️  Requires all mutations to create new object references (immutable updates):
//    posts = [...posts, newPost]  ✅ (new array reference)
//    posts.push(newPost)          ❌ (same array reference — OnPush won't detect)
Note: With OnPush, Angular skips checking a component unless one of these conditions triggers: an @Input() property changes by reference, an event originates from inside the component (click, keyboard, etc.), an async pipe emits a new value, or ChangeDetectorRef.markForCheck() is called. The critical implication: if you mutate an object that is passed as an input (post.title = 'new title'), OnPush components do NOT update because the object reference is the same. Always create new objects/arrays for input changes: post = { ...post, title: 'new title' }.
Tip: Signals work seamlessly with OnPush — Angular’s new reactive system tracks signal reads during rendering and automatically marks the component for re-checking when any read signal changes. This means you can use changeDetection: ChangeDetectionStrategy.OnPush with Signals without needing markForCheck() for signal-driven state changes. This combination — OnPush + Signals — is the modern recommended approach for Angular 18 components: maximum performance with clean reactive code.
Warning: OnPush requires immutable data patterns. A common bug: a parent component adds an item to an array (this.posts.push(newPost)) and passes it to an OnPush child. The array reference is unchanged, so the child does not re-render. The Angular-idiomatic fix: always create a new array (this.posts = [...this.posts, newPost]). This same principle applies to objects — spread to create a new reference: this.post = { ...this.post, viewCount: 42 }. Using Angular Signals eliminates this requirement because signals track value equality, not reference equality.

Common Mistakes

Mistake 1 — Mutating @Input objects in parent when child uses OnPush (no re-render)

❌ Wrong — this.post.title = 'new'; OnPush child still shows old title (same reference).

✅ Correct — this.post = { ...this.post, title: 'new' }; new reference triggers OnPush detection.

Mistake 2 — Calling external APIs and updating from outside the Angular zone without markForCheck

❌ Wrong — WebSocket callback outside Angular zone sets a property; OnPush component never updates.

✅ Correct — run in NgZone (this.ngZone.run(() => {...})) or call markForCheck() after the update.

🧠 Test Yourself

A parent adds an item to an array and passes it to an OnPush child: this.items.push(newItem). The child doesn’t update. Why, and what is the fix?