Change detection: zoneless and OnPush

Angular · Expert · 16 min

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 the async pipe), 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 call markForCheck).
  • 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.

Check yourself

  1. In a zoneless app, a promise callback sets this.title = 'x' (a plain field) and nothing else happens. Does the view update?
  2. A parent mutates an object passed as an input to an OnPush child (obj.name = 'B'). Why might the child not update?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: angular.dev/guide/zoneless