Change detection synchronizes reactive application state with rendered views. Learn Default and OnPush behavior, signal notifications, zoneless requirements, and the limited cases where manual ChangeDetectorRef control is appropriate.
Change detection evaluates template bindings and updates the DOM when Angular receives a notification that visible state may have changed. Zoneless scheduling is the default in Angular v21 and later; applications can still use signals, inputs, events, the async pipe, and explicit change-detection notifications.
During a check, Angular evaluates template bindings and updates DOM values whose results changed. It traverses the component tree once, so changing already-checked state during lifecycle hooks can produce ExpressionChangedAfterItHasBeenCheckedError in development.
Signals provide precise notifications when a template reads them. Template events, input assignments, async-pipe emissions, attached dirty views, and explicit change-detection APIs are other notifications Angular can use.
OnPush is the default strategy for new components in Angular v22. It lets Angular skip a clean subtree until a relevant notification marks it. Eager checks a component whenever traversal reaches it; ChangeDetectionStrategy.Default remains an alias for Eager and is deprecated.
OnPush is not manual rendering. Angular still checks after a changed bound input, an event handled in the subtree, a signal read by the template changes, an async-pipe emission, view attachment, or markForCheck. The notification contract matters more than the strategy label.
| Strategy | When it checks | Best for |
|---|---|---|
| OnPush | When the subtree receives a supported notification | Default choice and predictable state ownership |
| Eager | Whenever change-detection traversal reaches the component | Compatibility code that intentionally relies on eager checking |
| Default | Alias of Eager | Deprecated name retained for source compatibility |
When a parent mutates a property inside the same input object, the child receives no new input reference. Replace the object or collection so the ownership change is explicit, or place mutable state behind a signal the child reads.
Do not call detectChanges after every assignment to compensate for unclear state flow. First identify who owns the state and which normal notification should mark the view.
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
interface Product {
id: number;
name: string;
price: number;
}
@Component({
selector: 'app-product',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div>
<h3>{{ product.name }}</h3>
<p>${{ product.price }}</p>
</div>
`
})
export class ProductComponent {
@Input() product!: Product;
// Angular checks after a supported notification: an input update,
// a handled event, a consumed signal or AsyncPipe emission,
// view attachment, or markForCheck().
}
// IMPORTANT: With OnPush, mutating the object won't trigger re-render
// BAD: this.product.price = 20; // same reference - no update
// GOOD: this.product = { ...this.product, price: 20 }; // new reference
When a template reads a signal, Angular tracks that dependency and marks the consuming view when the signal changes. In a zoneless application, signals are one of several supported notifications and do not require Zone.js to schedule the update.
Zoneless does not mean signals are mandatory everywhere. Template listeners, input updates, async pipe, setInput, markForCheck, and attaching a dirty view can all notify Angular. A callback from a third-party API must eventually perform one of these notifications when it changes visible state.
Test loading, error, timer, overlay, and third-party integration paths during migration. Code that appeared to work only because Zone.js scheduled a broad check needs an explicit state notification.
import { Component, signal, computed } from '@angular/core';
@Component({
selector: 'app-counter',
standalone: true,
template: `
<p>Count: {{ count() }}</p>
<p>Doubled: {{ doubled() }}</p>
<button (click)="increment()">+1</button>
`
})
export class CounterComponent {
count = signal(0);
doubled = computed(() => this.count() * 2);
increment() {
this.count.update(v => v + 1);
}
// Angular only re-renders this component when count() changes
// No Zone.js needed - pure signal-based reactivity
}
import { provideZonelessChangeDetection } from '@angular/core';
import { TestBed } from '@angular/core/testing';
TestBed.configureTestingModule({
// Useful when zone.js is still loaded by the test polyfills.
providers: [provideZonelessChangeDetection()]
});
const fixture = TestBed.createComponent(StatusComponent);
fixture.componentInstance.refresh();
await fixture.whenStable();
expect(fixture.nativeElement.textContent).toContain('Ready');
markForCheck marks an OnPush view and its ancestors for a future check. detectChanges immediately checks a view and descendants. detach removes a view from normal traversal until reattach; a detached view can still be checked explicitly.
Manual APIs are appropriate for measured high-frequency or externally scheduled integrations, not as the first fix for stale UI. Document the scheduling contract and add a test that proves when the view updates.
import { Component, ChangeDetectionStrategy, ChangeDetectorRef, inject } from '@angular/core';
@Component({
selector: 'app-manual',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<p>{{ data }}</p>`
})
export class ManualComponent {
private cdr = inject(ChangeDetectorRef);
data = 'initial';
updateFromExternalSource() {
// Called from a third-party library callback (outside Angular)
this.data = 'updated';
this.cdr.markForCheck(); // tell Angular to check this component
}
pauseDetection() {
this.cdr.detach(); // stop checking this component
}
resumeDetection() {
this.cdr.reattach(); // resume checking
this.cdr.detectChanges(); // run one check immediately
}
}
The parent mutated product.price on the same object. OnPush input checking uses reference changes as an important notification, so the child may not be checked for that mutation.
Without Zone.js, Angular does not treat every asynchronous callback as a reason to check the application. A signal write, template event, input update, async-pipe emission, or explicit change-detection notification tells Angular that the view may need work.
detach suits views that update on their own schedule, such as a high-frequency telemetry panel. The component must call detectChanges to refresh; ordinary OnPush components usually only need markForCheck.
Explore 500+ free tutorials across 20+ languages and frameworks.