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)
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' }.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.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.