Angular ChangeDetectionStrategy.OnPush: Complete Guide
🔄 What is OnPush Change Detection?
OnPush is an optimization strategy in Angular that tells the framework to only check a component for changes when:
- Input properties (@Input) change by reference
- Events are triggered (click, submit, etc.)
- Observables emit new values (when using async pipe)
- Manual trigger via ChangeDetectorRef.detectChanges() or markForCheck()
📋 Angular Change Detection Strategies
1. ChangeDetectionStrategy.Default (CheckAlways)
@Component({
selector: 'app-default',
changeDetection: ChangeDetectionStrategy.Default, // or omit this line
template: `
<p>Count: {{ count }}</p>
<button (click)="increment()">+</button>
`
})
export class DefaultComponent {
count = 0;
increment() {
this.count++;
// Change detection runs automatically
}
}
Behavior:
- CheckAlways: Angular checks this component in every change detection cycle
- Runs for ANY change detection trigger in the entire app
- Most permissive but least performant
2. ChangeDetectionStrategy.OnPush (CheckOnce)
@Component({
selector: 'app-onpush',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<p>Count: {{ count }}</p>
<button (click)="increment()">+</button>
`
})
export class OnPushComponent {
count = 0;
increment() {
this.count++;
// Change detection WON'T run automatically!
// Need manual trigger
}
}
Behavior:
- CheckOnce: Only checks when specific conditions are met
- More restrictive but much more performant
🏁 Dirty Flags in Change Detection
Understanding Dirty Flags
Angular uses dirty flags to track which components need to be checked for changes. Each component has a dirty flag that determines whether it should be checked in the current change detection cycle.
CheckAlways (Default Strategy) - Dirty Flag Behavior:
@Component({
changeDetection: ChangeDetectionStrategy.Default
})
export class DefaultComponent {
// Dirty flag behavior:
// - ALWAYS marked as dirty
// - Checked in EVERY change detection cycle
// - Never skipped, regardless of changes
}
Dirty Flag Lifecycle:
- Always Dirty: Component is marked dirty in every cycle
- Always Checked: Angular always runs change detection
- Never Skipped: No optimization, always processes
CheckOnce (OnPush Strategy) - Dirty Flag Behavior:
@Component({
changeDetection: ChangeDetectionStrategy.OnPush
})
export class OnPushComponent {
// Dirty flag behavior:
// - ONLY marked dirty when specific conditions met
// - Checked ONLY when dirty flag is set
// - Dirty flag AUTOMATICALLY UNSET after first check
}
Dirty Flag Lifecycle:
- Conditionally Dirty: Only marked dirty when:
- @Input changes (by reference)
- Event triggered
- Observable emits (async pipe)
- Manual trigger called
- Single Check: Component checked once when dirty
- Auto-Reset: Dirty flag automatically unset after first change detection pass
- Skipped: Component skipped in subsequent cycles until marked dirty again
Visual Representation:
CheckAlways Strategy:
Cycle 1: [DIRTY] → Check → [DIRTY]
Cycle 2: [DIRTY] → Check → [DIRTY]
Cycle 3: [DIRTY] → Check → [DIRTY]
(Always checked, never optimized)
OnPush Strategy:
Cycle 1: [CLEAN] → Skip
Cycle 2: [DIRTY] → Check → [CLEAN] (auto-unset)
Cycle 3: [CLEAN] → Skip
Cycle 4: [CLEAN] → Skip
Cycle 5: [DIRTY] → Check → [CLEAN] (auto-unset)
🔴 Object Mutation Problem with OnPush
The Problem: Direct Object Property Mutation
@Component({
selector: 'user-profile',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div>
<h3>{{ user.firstName }} {{ user.lastName }}</h3>
<button (click)="changeFirstName()">Change First Name</button>
<button (click)="changeLastName()">Change Last Name</button>
</div>
`
})
export class UserProfileComponent {
user: User = {
firstName: 'Alice',
lastName: 'Smith'
};
constructor(private cdr: ChangeDetectorRef) {}
// ❌ This WON'T trigger change detection with OnPush
changeFirstName() {
this.user.firstName = 'Bob';
// Object reference is the same, only property changed
// OnPush doesn't detect this mutation!
}
// ❌ This WON'T trigger change detection either
changeLastName() {
this.user.lastName = 'Johnson';
// Same problem - mutating existing object
}
}
@Input() with Object Mutation Problem
// Parent Component
@Component({
selector: 'app-parent',
template: `
<user-display [user]="currentUser"></user-display>
<button (click)="updateUserName()">Update User Name</button>
<button (click)="updateUserProperly()">Update User Properly</button>
`
})
export class ParentComponent {
currentUser: User = {
firstName: 'Alice',
lastName: 'Smith',
email: 'alice@example.com'
};
// ❌ This WON'T trigger OnPush in child component
updateUserName() {
// Mutating object properties directly
this.currentUser.firstName = 'Bob';
this.currentUser.lastName = 'Johnson';
// Same object reference, child OnPush component won't detect change
}
// ✅ This WILL trigger OnPush in child component
updateUserProperly() {
// Creating new object reference
this.currentUser = {
...this.currentUser,
firstName: 'Bob',
lastName: 'Johnson'
};
// New reference triggers @Input change detection
}
}
// Child Component with OnPush
@Component({
selector: 'user-display',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div>
<h3>{{ user.firstName }} {{ user.lastName }}</h3>
<p>{{ user.email }}</p>
</div>
`
})
export class UserDisplayComponent {
@Input() user!: User;
// This component will only update when:
// 1. @Input() user gets a NEW REFERENCE
// 2. NOT when user properties are mutated directly
}
Why @Input() Mutation Doesn't Work:
// Before mutation
const userRef1 = this.currentUser; // Reference: 0x123456
// After mutation
this.currentUser.firstName = 'Bob';
const userRef2 = this.currentUser; // Reference: STILL 0x123456
// userRef1 === userRef2 → true
// Child component's @Input() sees no change because reference is identical
Solutions for @Input() Object Updates:
// ✅ Solution 1: Spread Operator
updateUser() {
this.currentUser = {
...this.currentUser,
firstName: 'Bob'
};
}
// ✅ Solution 2: Object.assign
updateUser() {
this.currentUser = Object.assign({}, this.currentUser, {
firstName: 'Bob'
});
}
// ✅ Solution 3: Immutable Libraries (Immer)
import { produce } from 'immer';
updateUser() {
this.currentUser = produce(this.currentUser, draft => {
draft.firstName = 'Bob';
});
}
// ✅ Solution 4: Manual trigger in child component
@Component({
selector: 'user-display',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserDisplayComponent {
@Input() user!: User;
constructor(private cdr: ChangeDetectorRef) {}
// If parent mutates object directly, manually trigger
ngOnChanges() {
this.cdr.markForCheck(); // Force check even with same reference
}
}
🛠️ ChangeDetectorRef Methods
When to Use detectChanges() vs markForCheck()
1. detectChanges() - Immediate Execution
Use When:
- Need immediate UI update
- Synchronous operations
- User interactions requiring instant feedback
- Critical updates that can't wait
@Component({
selector: 'app-immediate',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<p>Score: {{ score }}</p>
<button (click)="addPoints()">Add Points</button>
<p>Status: {{ status }}</p>
`
})
export class ImmediateComponent {
score = 0;
status = 'Ready';
constructor(private cdr: ChangeDetectorRef) {}
// ✅ Use detectChanges() for immediate user feedback
addPoints() {
this.score += 10;
this.status = 'Points Added!';
this.cdr.detectChanges(); // Immediate update
// Reset status after delay
setTimeout(() => {
this.status = 'Ready';
this.cdr.detectChanges(); // Another immediate update
}, 1000);
}
}
2. markForCheck() - Scheduled Execution
Use When:
- Asynchronous operations
- Observable subscriptions
- HTTP requests
- Better performance (batched updates)
- Multiple changes that can be grouped
@Component({
selector: 'app-async',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<p>Data: {{ data }}</p>
<p>Loading: {{ isLoading }}</p>
<button (click)="loadData()">Load Data</button>
`
})
export class AsyncComponent {
data = '';
isLoading = false;
constructor(
private cdr: ChangeDetectorRef,
private http: HttpClient
) {}
// ✅ Use markForCheck() for async operations
loadData() {
this.isLoading = true;
this.cdr.markForCheck(); // Schedule update
this.http.get<any>('/api/data').subscribe(response => {
this.data = response.message;
this.isLoading = false;
this.cdr.markForCheck(); // Schedule another update
});
}
// ✅ Also good for observables
ngOnInit() {
this.dataService.data$.subscribe(data => {
this.data = data;
this.cdr.markForCheck(); // Let Angular batch the update
});
}
}
3. Performance Comparison
@Component({
changeDetection: ChangeDetectionStrategy.OnPush
})
export class PerformanceComparisonComponent {
items: Item[] = [];
constructor(private cdr: ChangeDetectorRef) {}
// ❌ Poor performance - multiple immediate checks
loadDataPoorly() {
for (let i = 0; i < 100; i++) {
this.items.push(new Item(i));
this.cdr.detectChanges(); // 100 immediate change detection runs!
}
}
// ✅ Better performance - single scheduled check
loadDataEfficiently() {
for (let i = 0; i < 100; i++) {
this.items.push(new Item(i));
}
this.cdr.markForCheck(); // Single scheduled update for all changes
}
// ✅ Best performance - batch multiple operations
loadDataOptimally() {
// Multiple property changes
this.items = this.generateItems();
this.status = 'Loaded';
this.count = this.items.length;
// Single update for all changes
this.cdr.markForCheck();
}
}
4. detach() and reattach()
@Component({
selector: 'app-heavy-computation',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<p>Status: {{ status }}</p>
<p>Progress: {{ progress }}%</p>
<button (click)="startHeavyWork()">Start Work</button>
`
})
export class HeavyComputationComponent {
status = 'Ready';
progress = 0;
constructor(private cdr: ChangeDetectorRef) {}
async startHeavyWork() {
// Detach during heavy computation
this.cdr.detach();
this.status = 'Working...';
try {
for (let i = 0; i <= 100; i++) {
await this.doHeavyWork(i);
this.progress = i;
// Update UI every 10%
if (i % 10 === 0) {
this.cdr.detectChanges(); // Manual update while detached
}
}
this.status = 'Completed';
} finally {
// Re-attach when done
this.cdr.reattach();
this.cdr.markForCheck();
}
}
private doHeavyWork(iteration: number): Promise<void> {
return new Promise(resolve => {
// Simulate heavy computation
setTimeout(resolve, 50);
});
}
}
🎯 Method Selection Guide
Decision Matrix:
| Scenario | Method | Reason |
|---|---|---|
| Button click response | detectChanges() | Immediate user feedback needed |
| HTTP API response | markForCheck() | Async operation, can be batched |
| Observable subscription | markForCheck() | Better performance, scheduled update |
| Multiple property updates | markForCheck() | Batch all changes in one cycle |
| Heavy computation | detach() then reattach() | Avoid unnecessary checks during work |
| Real-time updates | detectChanges() | Immediate updates required |
| Form validation errors | detectChanges() | Instant feedback for user |
| Background data sync | markForCheck() | Non-critical, can wait |
Performance Impact:
| Method | Performance | When to Use |
|---|---|---|
markForCheck() | ⭐⭐⭐⭐⭐ Best | Async operations, multiple updates |
detectChanges() | ⭐⭐⭐ Good | Immediate updates, user interactions |
detach()/reattach() | ⭐⭐⭐⭐⭐ Best | Heavy computations, optimization |
Key Takeaways:
- OnPush dirty flags auto-reset after each change detection pass
- Default strategy components are always dirty (never optimized)
- @Input() only detects reference changes, not property mutations
- Use
markForCheck()for async operations (better performance) - Use
detectChanges()for immediate user feedback - Create new object references when updating @Input() objects
- Detach/reattach for extreme performance scenarios
Understanding dirty flags and method selection is crucial for optimal OnPush performance. The automatic reset of dirty flags in OnPush strategy is what makes it so efficient - components are only checked when necessary and automatically optimized afterward.
✅ When to Use OnPush
1. Pure Components (Recommended)
@Component({
selector: 'user-card',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="card">
<h3>{{ user.name }}</h3>
<p>{{ user.email }}</p>
<button (click)="onEdit()">Edit</button>
</div>
`
})
export class UserCardComponent {
@Input() user!: User;
@Output() edit = new EventEmitter<User>();
onEdit() {
this.edit.emit(this.user); // Events trigger change detection
}
}
2. Observable-Heavy Components with async pipe
@Component({
selector: 'product-list',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div *ngFor="let product of products$ | async">
{{ product.name }} - {{ product.price | currency }}
</div>
`
})
export class ProductListComponent {
products$ = this.store.select('products');
// async pipe automatically triggers change detection
}
3. Performance-Critical Components
@Component({
selector: 'large-table',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<tr *ngFor="let item of items; trackBy: trackByFn">
<td>{{ item.id }}</td>
<td>{{ item.name }}</td>
</tr>
`
})
export class LargeTableComponent {
@Input() items: Item[] = [];
trackByFn(index: number, item: Item) {
return item.id;
}
}
❌ When NOT to Use OnPush
1. Components with Internal State Mutations
// ❌ DON'T use OnPush here
@Component({
selector: 'timer',
template: `<p>Seconds: {{ seconds }}</p>`
})
export class TimerComponent implements OnInit {
seconds = 0;
ngOnInit() {
setInterval(() => {
this.seconds++; // Won't update with OnPush
}, 1000);
}
}
// ✅ Fixed version with OnPush
@Component({
selector: 'timer-fixed',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<p>Seconds: {{ seconds }}</p>`
})
export class TimerFixedComponent implements OnInit {
seconds = 0;
constructor(private cdr: ChangeDetectorRef) {}
ngOnInit() {
setInterval(() => {
this.seconds++;
this.cdr.markForCheck(); // Required!
}, 1000);
}
}
2. Third-Party Library Integration
// ❌ Be careful with OnPush
@Component({
selector: 'chart',
template: `
<div #chart></div>
<p>Status: {{ status }}</p>
`
})
export class ChartComponent implements AfterViewInit {
@ViewChild('chart') chartEl!: ElementRef;
status = 'Loading...';
ngAfterViewInit() {
// External library updates
someChartLibrary.create(this.chartEl.nativeElement, {
onComplete: () => {
this.status = 'Loaded'; // Won't update with OnPush
}
});
}
}
🎯 Best Practices Summary
Object Mutation Guidelines:
| Pattern | OnPush Compatible? | Example |
|---|---|---|
| Direct mutation | ❌ No | this.user.name = 'Bob' |
| @Input() mutation | ❌ No | Parent mutates object properties |
| Spread operator | ✅ Yes | this.user = {...this.user, name: 'Bob'} |
| Object.assign | ✅ Yes | this.user = Object.assign({}, this.user, {name: 'Bob'}) |
| Manual trigger | ✅ Yes | this.user.name = 'Bob'; this.cdr.markForCheck() |
OnPush is powerful but requires understanding of Angular's change detection lifecycle and immutable data patterns. When used correctly, it provides significant performance benefits, especially in large applications with complex