OnPush Change Detection
By default Angular checks every component on every change detection cycle, which is simple but can become a bottleneck in large applications. The OnPush strategy tells Angular to skip a component (and its subtree) unless something it actually depends on has changed. Understanding when OnPush components are checked — and how immutability and signals cooperate with it — is the key to building fast, predictable Angular UIs without fighting the framework.
What OnPush actually changes
Every component has a ChangeDetectionStrategy. With the Default strategy, Angular re-checks the component’s bindings during every change detection run, regardless of what triggered it. With OnPush, Angular treats the component as a black box that only needs re-rendering under specific, well-defined conditions.
You opt in per component:
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
@Component({
selector: 'app-user-card',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<article>
<h3>{{ user().name }}</h3>
<p>{{ user().email }}</p>
</article>
`,
})
export class UserCardComponent {
readonly user = input.required<{ name: string; email: string }>();
}
The component above will be re-rendered far less often than a Default one — but only because its inputs are read in a way OnPush can track.
When an OnPush component is checked
An OnPush component is checked in the current cycle only when one of these triggers fires for it:
| Trigger | Explanation |
|---|---|
| Input reference change | An @Input / input() receives a new reference (compared with ===), not a mutated object. |
| Template event | An event bound in the component’s own template fires (e.g. (click), (input)). |
| Async pipe emission | An observable consumed via ` |
| Signal change | A signal read in the template (directly or transitively) produces a new value. |
| Manual marking | Code calls ChangeDetectorRef.markForCheck(). |
If none of these happen, Angular skips the component and its descendants entirely — that is the performance win. Crucially, the skip is sticky downward: even a Default child nested under a skipped OnPush parent won’t be checked, because Angular never descends into the skipped subtree.
Tip: OnPush does not make change detection asynchronous or “lazy” — it simply prunes the component tree that gets walked on each cycle. The cycle still runs; it just does less work.
Immutability is the contract
Because input checks use reference equality, mutating an object in place is the classic OnPush bug. The reference stays the same, so Angular never sees a change.
// BAD — mutation: the reference is unchanged, OnPush won't update.
addItem(item: Item) {
this.items.push(item); // same array reference
}
// GOOD — new reference on every change.
addItem(item: Item) {
this.items = [...this.items, item];
}
The same applies to objects: replace, don’t patch.
// GOOD
updateName(name: string) {
this.user = { ...this.user, name };
}
This is why OnPush pairs so naturally with immutable data patterns, state libraries like NgRx, and the async pipe — all of them hand the component fresh references rather than mutating existing ones.
Signals and OnPush
Signals are the modern answer to OnPush’s ergonomic friction. When a component reads a signal in its template, Angular records a dependency and automatically marks the component for check whenever that signal changes — no markForCheck(), no immutability discipline required for the binding itself.
import { ChangeDetectionStrategy, Component, signal, computed } from '@angular/core';
@Component({
selector: 'app-counter',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<p>Count: {{ count() }}</p>
<p>Doubled: {{ doubled() }}</p>
@if (count() > 5) {
<strong>High!</strong>
}
<button (click)="increment()">+1</button>
`,
})
export class CounterComponent {
readonly count = signal(0);
readonly doubled = computed(() => this.count() * 2);
increment() {
this.count.update((n) => n + 1);
}
}
Here, calling count.update(...) schedules a refresh of this OnPush component precisely because the template reads count(). Signal inputs (input()) behave the same way: a new input value updates the signal graph and marks the component. This makes OnPush + signals the recommended default for new Angular components, and it is the foundation for zoneless change detection.
Warning: Signals only trigger a refresh when read in the template (or in a
computed/effectthat feeds the template). A signal read only inside an event handler won’t mark the view by itself.
Forcing a check manually
When a change happens outside Angular’s tracked triggers — a third-party callback, a WebSocket message, a mutated object you can’t easily clone — inject ChangeDetectorRef and mark the component.
import { ChangeDetectorRef, Component, inject, ChangeDetectionStrategy } from '@angular/core';
@Component({
selector: 'app-feed',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<p>Last message: {{ message }}</p>`,
})
export class FeedComponent {
private readonly cdr = inject(ChangeDetectorRef);
message = '';
constructor() {
externalSocket.onMessage((text: string) => {
this.message = text;
this.cdr.markForCheck(); // re-include this component next cycle
});
}
}
Use markForCheck() (schedules a check) rather than detectChanges() (runs an immediate, synchronous check) in almost all OnPush scenarios — prefer signals or the async pipe over manual marking whenever possible.
Output:
Last message: Hello from the server
Best Practices
- Default to
ChangeDetectionStrategy.OnPushfor every new component; treatDefaultas the exception. - Prefer signals and the
asyncpipe so views refresh automatically without manualmarkForCheck()calls. - Never mutate inputs or state in place — replace objects and arrays to produce new references.
- Keep components small so a skipped OnPush subtree prunes as much work as possible.
- Reach for
markForCheck()only at integration boundaries (third-party callbacks, manual subscriptions); avoiddetectChanges()unless you truly need a synchronous re-render. - Verify behaviour by profiling with Angular DevTools rather than guessing where checks happen.