Angular 62 🅰️ Change Detection — Default and OnPush
Change detection is the mechanism that keeps the DOM synchronized with the application’s state. Every time a user clicks a button, a timer fires, or a signal updates, Angular needs to know which parts of the view are stale and need to be re-rendered. The strategy that determines this is set per-component, and it is one of the most consequential performance decisions in an Angular application. The Default strategy checks the component eagerly, meaning it is a candidate for checking on every cycle. The OnPush strategy checks the component only when specific triggers fire — a new input reference, an event from within the component, or an explicit markForCheck(). The difference between the two is the difference between an application that scales and one that slows down as the component tree grows.
Key point: ChangeDetectionStrategy.Default (also called Eager in recent versions) checks the component during every change detection cycle. ChangeDetectionStrategy.OnPush uses the CheckOnce strategy: the component is checked initially, and then only when a new input reference arrives, when an event originates from the component or its children, or when ChangeDetectorRef.markForCheck() is called. Signals integrate with OnPush by marking the component dirty automatically when a signal read in the template updates. The default strategy has been renamed to Eager in recent versions, and OnPush is becoming the default for new components in Angular 22+.
The Default (Eager) strategy
The Default strategy, historically called CheckAlways, checks the component on every change detection cycle. When any event fires anywhere in the application — a click, a timer, an HTTP response — Angular runs change detection from the root and checks every component that uses the Default strategy.
import { Component } from '@angular/core';
@Component({
selector: 'app-counter',
template: `
<p>{{ count }}</p>
<button (click)="increment()">+</button>
`,
})
export class CounterComponent {
count = 0;
increment(): void {
this.count++;
}
}
When the button is clicked, the increment() method runs, count changes, and Angular automatically updates the DOM. The Default strategy makes this work without any explicit notification — the change detection cycle runs after the event, and the component is checked.
Why Default is the historical starting point. The Default strategy is the simplest mental model. The developer mutates a property, and the view updates. There is no reference comparison to worry about, no markForCheck() to call, and no trigger list to remember. For small applications, the cost of checking every component is negligible, and the simplicity is worth more than the performance.
Why Default becomes a problem at scale. In a large application with hundreds of components, checking every component on every event becomes expensive. A single click in one part of the application triggers a full traversal of the component tree. The check itself is fast per component, but the aggregate cost grows with the tree’s size. The Default strategy is the reason large Angular applications historically needed careful optimization.
Why the rename to Eager matters. Recent Angular versions have renamed Default to Eager, and the Default name is deprecated . The rename reflects the strategy’s actual behavior: it checks eagerly, without waiting for a specific trigger. The Eager name makes the cost explicit — the component is always a candidate for checking, and the developer accepts that cost in exchange for the simplicity.
The OnPush strategy
The OnPush strategy, formally CheckOnce, instructs Angular to skip the component during change detection unless one of a specific set of triggers fires. The component is checked once when it is first rendered, and then only on the triggers.
import { Component, ChangeDetectionStrategy } from '@angular/core';
@Component({
selector: 'app-user-card',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<p>{{ user.name }}</p>`,
})
export class UserCardComponent {
user = { name: 'Alice' };
}
The changeDetection: ChangeDetectionStrategy.OnPush sets the strategy. The component is checked initially, and then only when an input’s reference changes, when an event originates from the component or its children, or when markForCheck() is called .
The input reference trigger. When a parent passes a new value to an @Input property, Angular compares the previous and current values with ==. If they are different, the OnPush component is checked . This is why OnPush components require immutable inputs — mutating an object’s property without changing the reference does not trigger the check.
// The parent:
<app-user-card [user]="user" />
// The child (OnPush):
@Input() user!: User;
// The parent's update — the reference changes:
this.user = { ...this.user, name: 'Bob' }; // ✅ triggers
// The parent's update — the reference stays the same:
this.user.name = 'Bob'; // ❌ does not trigger
The event trigger. When an event originates from within the OnPush component or any of its children — a click, a keydown, an output emission, a @HostListener — Angular checks the component . The event is a signal that the component’s state may have changed in response to the user’s interaction.
Why OnPush is the performance strategy. OnPush reduces the number of components checked per cycle. A component subtree with an OnPush root is skipped unless it receives new inputs or handles an event. The savings compound: a large subtree that does not depend on the changed state is skipped entirely .
Why OnPush requires discipline. The developer must ensure that every state change that should update the view triggers one of the OnPush triggers. A mutation that preserves the reference does not trigger the check. A value updated outside the component’s event scope does not trigger the check. The discipline is the cost of the performance.
Signals and OnPush
Signals integrate with OnPush by marking the component dirty automatically when a signal read in the template updates. This is the key to understanding why signals and OnPush are a natural pair.
import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
@Component({
selector: 'app-counter',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<p>{{ count() }}</p>
<button (click)="increment()">+</button>
`,
})
export class CounterComponent {
count = signal(0);
increment(): void {
this.count.update((v) => v + 1);
}
}
When this.count.update() runs, the signal notifies its consumers. Because the template reads count(), the component is marked dirty, and Angular schedules a change detection cycle for it . The signal update is the trigger, and the OnPush component is checked.
Why signals and OnPush are the modern pair. Before signals, an OnPush component needed markForCheck() to be called explicitly whenever state changed outside the input or event triggers. Signals eliminate that boilerplate: updating a signal that is read in the template is a first-class trigger, and the component is checked automatically . The combination is the recommended pattern for new Angular code.
Why signals do not make OnPush unnecessary. A signal read in the template marks the component dirty, but a signal that is not read in the template does not. The OnPush strategy still determines when the component is checked, and the signal’s read in the template is what registers the dependency. The two work together, and neither replaces the other.
The markForCheck() method
The markForCheck() method on ChangeDetectorRef explicitly marks the component and its ancestors as dirty, scheduling a change detection cycle.
import { Component, ChangeDetectorRef, inject } from '@angular/core';
@Component({
selector: 'app-user',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<p>{{ user.name }}</p>`,
})
export class UserComponent {
private readonly cdr = inject(ChangeDetectorRef);
user = { name: 'Alice' };
updateFromOutside(): void {
this.user.name = 'Bob';
this.cdr.markForCheck(); // ✅ schedules the check
}
}
The cdr.markForCheck() tells Angular that the component’s view may be stale. Without it, the mutation of user.name preserves the reference, and the OnPush component would not be checked .
Why markForCheck() is the escape hatch. The method is for the cases where the OnPush triggers do not fire but the view needs updating. A value updated in a setTimeout, a subscription callback, or an async operation that does not originate from the component’s event scope needs an explicit notification.
Why markForCheck() is a code smell when overused. Frequent markForCheck() calls suggest that the component’s state is not flowing through the OnPush triggers. The signal-based approach eliminates most of these calls. When markForCheck() appears in a codebase with signals, it is usually a sign that a non-signal state is being used, or that the state is being mutated in a way that does not trigger the check.
The Default vs OnPush comparison
| Aspect | Default (Eager) | OnPush |
|---|---|---|
| Check frequency | Every cycle | On triggers |
| Input comparison | Not required | Reference comparison |
| Mutation detection | Automatic | Reference change required |
markForCheck() | Not needed | Required for out-of-trigger changes |
| Performance | Checks all components | Skips subtrees |
| Complexity | Low | Higher |
| Signals | Works | Natural fit |
Why the comparison matters. The Default strategy is easier to write and harder to optimize. The OnPush strategy is harder to write correctly and easier to optimize. The choice depends on the application’s scale and the team’s discipline.
The zoneless future
The zoneless change detection removes zone.js and relies on Angular’s APIs — signals, markForCheck(), setInput(), and bound listeners — to schedule change detection . The zoneless provider is stable as of Angular 20.2, and new applications default to zoneless from Angular 21 .
Why zoneless and OnPush are connected. Zoneless change detection requires the components to be OnPush-correct or signal-driven. Without zone.js patching the asynchronous operations, the only triggers are Angular’s APIs. A component that mutates a plain property outside an event handler does not trigger change detection in a zoneless application .
Why the event handlers still trigger. In a zoneless application, bound host or template listeners still trigger change detection . A (click) handler marks the component and its ancestors dirty, even without zone.js. This is why some applications appear to work when migrated to zoneless — the event handlers are providing the triggers.
Why the migration requires auditing. The migration to zoneless requires auditing every state change that should update the view. A setInterval that mutates a property does not trigger change detection in zoneless . The fix is to use a signal, call markForCheck(), or convert the state to a signal.
Why OnPush is becoming the default. Angular 22 makes OnPush the default change detection strategy. A component with an undefined changeDetection is OnPush, not Eager . The Default strategy is deprecated and renamed to Eager, and the explicit opt-in is ChangeDetectionStrategy.Eager for the components that need the eager checking .
Complete Example Session
// ============================================
// PART 1: THE DEFAULT STRATEGY
// ============================================
@Component({
selector: 'app-default',
template: `<p>{{ count }}</p><button (click)="inc()">+</button>`,
})
export class DefaultComponent {
count = 0;
inc(): void { this.count++; }
}
// ============================================
// PART 2: THE ONPUSH STRATEGY
// ============================================
@Component({
selector: 'app-onpush',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<p>{{ user.name }}</p>`,
})
export class OnPushComponent {
@Input() user!: User;
}
// ============================================
// PART 3: THE INPUT REFERENCE
// ============================================
// The parent:
this.user = { ...this.user, name: 'Bob' }; // ✅ triggers the OnPush check
this.user.name = 'Bob'; // ❌ does not trigger
// ============================================
// PART 4: THE EVENT TRIGGER
// ============================================
@Component({
selector: 'app-event',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<button (click)="update()">Update</button><p>{{ data }}</p>`,
})
export class EventComponent {
data = 'initial';
update(): void {
this.data = 'updated'; // ✅ the click event triggers the check
}
}
// ============================================
// PART 5: THE markForCheck
// ============================================
@Component({
selector: 'app-mark',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<p>{{ value }}</p>`,
})
export class MarkComponent {
private readonly cdr = inject(ChangeDetectorRef);
value = 'initial';
updateFromTimer(): void {
setTimeout(() => {
this.value = 'updated';
this.cdr.markForCheck(); // ✅ schedules the check
}, 1000);
}
}
// ============================================
// PART 6: THE SIGNALS AND ONPUSH
// ============================================
@Component({
selector: 'app-signal',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<p>{{ count() }}</p><button (click)="inc()">+</button>`,
})
export class SignalComponent {
count = signal(0);
inc(): void {
this.count.update((v) => v + 1); // ✅ the signal marks dirty
}
}
// ============================================
// PART 7: THE ZONELESS SETUP
// ============================================
export const appConfig: ApplicationConfig = {
providers: [
provideZonelessChangeDetection(), // the stable API since v20.2
provideRouter(routes),
provideHttpClient(),
],
};
// ============================================
// PART 8: THE ZONELESS TRIGGERS
// ============================================
// The valid triggers:
// - ChangeDetectorRef.markForCheck
// - ComponentRef.setInput
// - Updating a signal read in the template
// - Bound host or template listeners
// - Attaching a view marked dirty
// The invalid trigger (no change detection):
setInterval(() => {
this.plainProperty = 'updated'; // ❌ does not trigger
}, 1000);
// ============================================
// PART 9: THE EAGER OPT-IN (Angular 22+)
// ============================================
@Component({
selector: 'app-eager',
changeDetection: ChangeDetectionStrategy.Eager, // the explicit opt-in
template: `<p>{{ count }}</p>`,
})
export class EagerComponent {
count = 0;
}
// ============================================
// PART 10: WHAT NOT TO DO
// ============================================
// Don't mutate an object reference for an OnPush input
this.user.name = 'Bob'; // ❌ no trigger
// Don't assume zoneless triggers on plain property mutation
this.plainProperty = 'updated'; // ❌ no trigger in zoneless
// Don't overuse markForCheck
this.cdr.markForCheck(); // ⚠️ the signal is the better fix
// Don't use the Default strategy for the new components
changeDetection: ChangeDetectionStrategy.Default // ⚠️ deprecated in v22
// Don't forget the OnPush requires immutable inputs
this.user = { ...this.user, name: 'Bob' }; // ✅
The ten parts cover the Default strategy, the OnPush strategy, the input reference, the event trigger, the markForCheck, the signals and OnPush, the zoneless setup, the zoneless triggers, the eager opt-in, and the anti-patterns.
Quick Reference
The Strategies
| Strategy | Behavior | Trigger |
|---|---|---|
| Default (Eager) | Every cycle | Always |
| OnPush | On triggers | Input reference, event, markForCheck |
The OnPush Triggers
| Trigger | Example |
|---|---|
| Input reference change | [user]="newUser" |
| Event from the component | (click)="handle()" |
markForCheck() | cdr.markForCheck() |
| Signal update read in template | count.update() |
The Zoneless Triggers
| Trigger | API |
|---|---|
| Signal update | signal.set(), signal.update() |
markForCheck | ChangeDetectorRef.markForCheck() |
| Input set | ComponentRef.setInput() |
| Bound listeners | (click), (keydown) |
The Comparison
| Aspect | Default | OnPush |
|---|---|---|
| Check frequency | Every cycle | On triggers |
| Mutation detection | Automatic | Reference change |
markForCheck | Not needed | Required for out-of-trigger |
| Performance | Slower at scale | Faster |
| Complexity | Low | Higher |
Best Practices
✅ Do This:
// Use OnPush for the new components
@Component({ changeDetection: ChangeDetectionStrategy.OnPush })
// Use signals for the state read in the template
count = signal(0);
// Use the immutable updates for the OnPush inputs
this.user = { ...this.user, name: 'Bob' };
// Use markForCheck for the out-of-trigger changes
this.cdr.markForCheck();
// Use the zoneless provider for the new applications
provideZonelessChangeDetection()
❌ Don’t Do This:
// Don't mutate the object reference for the OnPush inputs
this.user.name = 'Bob'; // no trigger
// Don't assume the plain property mutation triggers in zoneless
this.plainProperty = 'updated'; // no trigger
// Don't overuse markForCheck when signals are the fix
this.cdr.markForCheck(); // use signal
// Don't use the Default strategy for the new components
changeDetection: ChangeDetectionStrategy.Default // deprecated
Common Pitfalls
| Pitfall | Problem | Solution |
|---|---|---|
| Object mutation | No OnPush trigger | Immutable update |
| Plain property in zoneless | No trigger | Use signal |
Missing markForCheck | Stale view | Call it explicitly |
Overusing markForCheck | Code smell | Use signals |
| Default strategy in large trees | Slow | Use OnPush |
| Zoneless without audit | Broken triggers | Audit state changes |
Real-World Examples
1. The OnPush component
@Component({ changeDetection: ChangeDetectionStrategy.OnPush })
2. The immutable input
this.user = { ...this.user, name: 'Bob' };
3. The signal state
count = signal(0);
4. The markForCheck
this.cdr.markForCheck();
5. The zoneless provider
provideZonelessChangeDetection()
6. The event trigger
(click)="handle()"
7. The eager opt-in
changeDetection: ChangeDetectionStrategy.Eager
8. The input reference
[user]="newUser"
9. The signal update
this.count.update((v) => v + 1);
10. The OnPush default (v22+)
// Omit changeDetection for OnPush default
Visual: The Default vs OnPush
┌──────────────────────────────────────────────┐
│ DEFAULT (EAGER) │
│ │
│ Event fires │
│ │ │
│ ▼ │
│ Check every component │
│ │ │
│ ├── Header ✅ checked │
│ ├── Sidebar ✅ checked │
│ └── Main ✅ checked │
│ │
│ All components are checked. │
│ │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ ONPUSH │
│ │
│ Event fires in Main │
│ │ │
│ ▼ │
│ Check Main and its subtree │
│ │ │
│ ├── Header ❌ skipped │
│ ├── Sidebar ❌ skipped │
│ └── Main ✅ checked │
│ │
│ Only the affected subtree is checked. │
│ │
└──────────────────────────────────────────────┘
Visual: The OnPush Triggers
┌──────────────────────────────────────────────┐
│ THE ONPUSH COMPONENT IS CHECKED WHEN: │
│ │
│ 1. A new input reference arrives │
│ [user]="newUser" │
│ │
│ 2. An event fires from the component │
│ (click)="handle()" │
│ │
│ 3. markForCheck() is called │
│ this.cdr.markForCheck() │
│ │
│ 4. A signal read in the template updates │
│ this.count.update((v) => v + 1) │
│ │
│ Otherwise, the component is skipped. │
│ │
└──────────────────────────────────────────────┘
Visual: The Zoneless Triggers
┌──────────────────────────────────────────────┐
│ THE ZONELESS APPLICATION DETECTS: │
│ │
│ ✅ signal.set() or signal.update() │
│ ✅ ChangeDetectorRef.markForCheck() │
│ ✅ ComponentRef.setInput() │
│ ✅ Bound host or template listeners │
│ │
│ ❌ Plain property mutation │
│ ❌ setInterval without signal │
│ ❌ setTimeout without markForCheck │
│ │
│ The valid triggers are the Angular APIs. │
│ │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Default (Eager) | Checks every cycle |
| OnPush | Checks on triggers |
| Input trigger | Reference comparison |
| Event trigger | Component or child event |
| Explicit trigger | markForCheck() |
| Signal trigger | Signal read in template |
| Zoneless | Stable in v20.2, default in v21+ |
| OnPush default | v22+ |
| Eager opt-in | ChangeDetectionStrategy.Eager |
Key takeaways:
- The Default strategy checks the component on every cycle — it is simple but becomes expensive at scale, and it has been renamed to
Eager - The OnPush strategy checks the component only on triggers — a new input reference, an event from the component,
markForCheck(), or a signal update read in the template - OnPush requires immutable inputs — the reference comparison is the trigger, and mutating an object’s property without changing the reference does not trigger the check
- Signals and OnPush are the modern pair — a signal read in the template marks the component dirty automatically, eliminating most
markForCheck()calls markForCheck()is the escape hatch — it is for the state changes that occur outside the OnPush triggers, and it is a code smell when overused- Zoneless change detection relies on Angular’s APIs — signals,
markForCheck(),setInput(), and bound listeners are the triggers, and plain property mutations do not trigger - The zoneless provider is stable as of v20.2 — new applications default to zoneless from v21, and the
provideZonelessChangeDetection()is the API - OnPush is the default from v22 — the
Defaultstrategy is deprecated and renamed toEager, and the explicit opt-in isChangeDetectionStrategy.Eager - The zoneless migration requires auditing — every state change that should update the view must flow through a valid trigger
- The event handlers still trigger in zoneless — bound host or template listeners are the triggers, which is why some applications appear to work when migrated
Remember: Change detection is the mechanism that keeps the view synchronized with the state. The Default (Eager) strategy checks everything; the OnPush strategy checks on triggers. Signals and OnPush are the modern pair, and zoneless is the future. The migration to OnPush and zoneless requires discipline — immutable inputs, signals for state, and explicit markForCheck() when the triggers do not fire. The performance gain is the reward, and the discipline is the cost.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!