Change detection is the source of most Angular performance problems and the 'view didn't update' class of bugs.
Change detection is how Angular syncs component state to the DOM: it walks component views and re-evaluates template bindings, updating the DOM where values changed.
Historically, zone.js patched browser async APIs and triggered a check of the whole app after every event, timer or XHR. Since Angular 21, new apps are zoneless: nothing is monkey-patched, and Angular schedules change detection only when it is notified. Notifications come from:
- a signal read in a template changing,
- a template or host event listener firing,
markForCheck()(also called by theasyncpipe),setInput, or attaching a view.
Angular 22 also makes `OnPush` the default strategy (the old behaviour is now called ChangeDetectionStrategy.Eager). An OnPush component is checked only when it is marked dirty: its inputs changed (by reference), an event fired in it, or a signal it reads changed. Signals go further: a signal change marks only the consuming component for refresh, so Angular can skip clean ancestors.
Consequences:
- Mutating a plain field from a
setTimeout, promise or third-party callback won't update the view. Use signals (or callmarkForCheck). - Mutating an input object in place doesn't change its reference, so OnPush children won't refresh.
- In development, Angular runs a second check and throws NG0100 ExpressionChangedAfterItHasBeenChecked if a binding changed during the check, which points to state updates in the wrong place.
Migrating older apps: provideZonelessChangeDetection() and Eager components ease the transition.
Example
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, signal } from '@angular/core';
@Component({
selector: 'app-status', // OnPush by default in v22
template: `<p>Signal: {{ status() }}</p> <p>Plain field: {{ plain }}</p>`,
})
export class Status {
protected status = signal('waiting');
protected plain = 'waiting';
private cdr = inject(ChangeDetectorRef);
constructor() {
setTimeout(() => this.status.set('done'), 1000); // signal notifies Angular -> re-render
setTimeout(() => (this.plain = 'stale?'), 2000); // nothing notifies: view keeps old text
setTimeout(() => {
this.plain = 'really done';
this.cdr.markForCheck(); // manual notification for plain state
}, 3000);
}
}
@Component({
selector: 'app-legacy',
changeDetection: ChangeDetectionStrategy.Eager, // opt out of OnPush
template: `{{ counter }}`,
})
export class Legacy { counter = 0; }Remember
- Zoneless Angular renders only when notified: signals, template events, markForCheck
- OnPush is the default in v22; components refresh when marked dirty
- Prefer signals for all template state; mutate inputs immutably
- NG0100 in dev reveals state changed during a check
Watch out for
- Updating plain fields in timers or library callbacks and wondering why the zoneless view doesn't refresh.