Angular OnPush: Why Your Component Stays Stale and How to Fix It
OnPush skips change detection unless an @Input reference changes, a template event fires, or the async pipe emits. Learn why mutating arrays in place causes stale views and how to use immutable updates to fix it.
29 Aug 2026, 04:42 UTC

The Stale View Problem
You pass an array to a child component. The child uses ChangeDetectionStrategy.OnPush. Somewhere in your code you call items.push(newItem). The UI doesn't update. No error, no warning—just a view that silently disagrees with your data.
This is the classic OnPush trap. Angular's default change detection (CheckAlways) re-evaluates every binding after any asynchronous event. OnPush opts out of that blanket re-check. It only refreshes when a specific trigger fires. If you mutate an object in place, the reference stays the same, OnPush sees no change, and the view stalls.
What Actually Triggers OnPush
An OnPush component (and its entire subtree) is checked only when one of these occurs:
- An
@Inputreference changes (identity comparison,===) - An event handler bound in the component's own template fires (including
@Outputhandlers used there) ChangeDetectorRef.markForCheck()ordetectChanges()is called- An observable rendered through the
asyncpipe emits
Notice what's not on the list: deep equality checks, property mutations, or changes inside nested objects. The input must be a new reference.
Worked Example: Immutable Update vs. In-Place Mutation
Create a fresh Angular app (ng new onpush-demo --defaults) and add two components.
// parent.component.ts
import { Component, signal } from '@angular/core';
import { ChildComponent } from './child.component';
@Component({
selector: 'app-parent',
standalone: true,
imports: [ChildComponent],
template: `
<button (click)=\"push()\">Push (mutate)</button>
<button (click)=\"replace()\">Replace (new ref)</button>
<app-child [items]=\"items()\" />
`,
})
export class ParentComponent {
items = signal<string[]>(['first']);
push() {
// Mutates in place — reference unchanged
this.items.update(arr => { arr.push('new'); return arr; });
}
replace() {
// New array reference — triggers OnPush
this.items.update(arr => [...arr, 'new']);
}
}
// child.component.ts
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
@Component({
selector: 'app-child',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<ul>
@for (item of items; track item) {
<li>{{ item }}</li>
}
</ul>
`,
})
export class ChildComponent {
@Input() items: string[] = [];
}
Click Push: the signal updates, but the array reference is identical. OnPush skips the child. The list stays at one item.
Click Replace: a new array is created. The input reference differs. OnPush checks the child. The list grows.
The Async Pipe Shortcut
When data arrives as an observable, the async pipe is the cleanest OnPush partner. It subscribes, unsubscribes on destroy, and calls markForCheck() on every emission.
// child.component.ts (stream version)
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
import { Observable } from 'rxjs';
import { AsyncPipe } from '@angular/common';
@Component({
selector: 'app-child',
standalone: true,
imports: [AsyncPipe],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<ul>
@for (item of items$ | async; track item) {
<li>{{ item }}</li>
}
</ul>
`,
})
export class ChildComponent {
@Input() items$!: Observable<string[]>;
}
Escape Hatches: markForCheck vs. detectChanges
Sometimes you must integrate with non-Angular APIs (third-party libraries or WebSocket callbacks). Two methods exist:
| Method | Behavior | Typical Use |
|---|---|---|
markForCheck() |
Flags this component and ancestors for the next scheduled change-detection run | Inside callbacks, promise .then(), third-party streams |
detectChanges() |
Runs change detection synchronously for this component and descendants | Rare; when you need the DOM updated before the next line executes |
Trade-offs and Limitations
OnPush trades simplicity for discipline:
- Immutability required. Every input update must produce a new reference. Teams need conventions (spread operator or signals) and code-review awareness.
- Subtree skipping. An OnPush parent skips its entire subtree. A default-strategy child under an OnPush ancestor will not be checked while the ancestor is skipped.
- Debugging friction. Stale views leave no stack trace. Use the Angular DevTools profiler to verify which components are actually being checked.
- Not automatically faster. Gains come from skipping large subtrees. A tiny component tree sees no measurable difference.
Signals and the Modern Baseline
Angular 16 introduced signals (stable in v17). OnPush components that read signals in their templates still refresh when those signals change. OnPush remains the recommended baseline for new components—it's an explicit opt-in to fine-grained reactivity.
Verify It Yourself
- Run the worked example above in a fresh
ng newproject. - Open Angular DevTools → Profiler → Record a change-detection cycle after each button click.
- Observe that
app-childappears in the checked list only after Replace. - Write a component spec: mutate the input in place, call
fixture.detectChanges(), assert the DOM is unchanged; then replace the reference, detect changes again, and assert the DOM updated.
Closing Checklist
- Default to OnPush for new presentational components.
- Keep inputs immutable—replace arrays/objects, don't mutate.
- Use the
asyncpipe for observable inputs. - Reserve
markForCheck()for genuine external callbacks. - Profile with Angular DevTools before claiming performance wins.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.