01 · Change Detection & Zoneless¶
Change detection is how Angular keeps the DOM in sync with your component state: it walks component views, re-evaluates template bindings, and writes the ones that changed. Two questions define it:
- When does a change-detection pass run?
- Which views does a pass actually check?
Angular's answers changed a lot in recent versions, and Angular 22 has new defaults for both. If you learned Angular before about 2025, some of your instincts are now wrong — this lesson shows exactly which, with behaviour observed in a real browser.
The defaults in Angular 22¶
- Zoneless. New applications don't load Zone.js. In our test app,
typeof Zonein the browser console wasundefined. OnPushby default. The type definitions forChangeDetectionStrategyin 22 say "NOTE: OnPush is enabled by default." The old always-check behaviour is now calledChangeDetectionStrategy.Eager(Defaultremains as a deprecated alias for it).
When does a pass run? (the "when")¶
With Zone.js, Angular patched setTimeout, promises, XHR, DOM events and more, and ran
change detection after any of them finished — whether or not anything changed. Zoneless
Angular only schedules a pass when it is told something changed. The API documentation
for provideZonelessChangeDetection lists the notifications:
- updating a signal that is read in a template
- a bound template or host listener firing (
(click),host: { '(keydown)': ... }) ChangeDetectorRef.markForCheck()ComponentRef.setInput(...)- attaching a view that was marked dirty, or removing a view
- registering a render hook (which then must do one of the above)
Plain assignments to fields are not on the list.
Which views are checked? (the "which")¶
An OnPush component is only refreshed during a pass if it was marked dirty, which
happens when:
- a signal its template reads changes,
- one of its inputs receives a new value (compared with
Object.is), - an event listener in its template or host fires,
markForCheck()is called on it (which also marks its ancestors).
An Eager component is refreshed on every pass that reaches it.
What we observed¶
We built a lab component (OnPush by default) with a plain field, a signal, an OnPush
child and an Eager child — both children receive the same profile object input — and
clicked buttons in Chromium, reading the DOM 150 ms after each click:
plainCount = 0;
readonly sigCount = signal(0);
profile: Profile = { name: 'Ada' };
laterPlain() { setTimeout(() => this.plainCount++, 0); }
laterSignal() { setTimeout(() => this.sigCount.update((n) => n + 1), 0); }
laterMarked() { setTimeout(() => { this.plainCount++; this.cdr.markForCheck(); }, 0); }
mutateProfile() { this.profile.name = 'Grace'; } // same object
replaceProfile(){ this.profile = { name: 'Linus' }; } // new object
action plain sig OnPush child Eager child
initial 0 0 Ada Ada
plain field in setTimeout 0 0 Ada Ada
signal in setTimeout 1 1 Ada Ada
plain + markForCheck 2 1 Ada Ada
mutate input object (click) 2 1 Ada Grace
replace input object (click) 2 1 Linus Linus
Reading it row by row:
- Plain field in
setTimeout: the value changed to 1 in memory, but nothing told Angular. The screen still said 0. (With Zone.js this would have updated — this is the single most common surprise when moving to zoneless.) - Signal in
setTimeout: the signal notified Angular, the view refreshed — and the stale plain field was picked up by accident in the same pass, showing 1. Bugs of the first kind often hide behind this: something else refreshes the view, so it "usually works". markForCheck(): explicitly marking the view works for non-signal state.- Mutating the input object: the click marked the parent dirty, so the parent
re-evaluated
[profile]="profile"— but it's the same object, so the OnPush child's input didn't change and the child was skipped. The Eager child was checked anyway and showed "Grace". - Replacing the object: a new reference, so both children updated.
The practical rules¶
- Keep template state in signals (or in things that become signals: inputs,
toSignal, resources, forms). Then "when" and "which" take care of themselves. - Replace, don't mutate objects and arrays passed as inputs or held in signals.
- If you must change non-signal state asynchronously (a third-party callback, a
setTimeout), callmarkForCheck()— or better, write it into a signal. - Use
Eageronly as a migration crutch for components that rely on mutation.
Zone.js apps¶
Many existing apps still use Zone.js. They include zone.js in polyfills and either
rely on older defaults or call provideZoneChangeDetection({ eventCoalescing: true }).
Everything in this lesson about which views are checked still applies; the difference is
when: Zone.js triggers a pass after every async task. Migrating means removing
zone.js, adding provideZonelessChangeDetection() if needed, and fixing the places that
relied on async tasks to trigger rendering — the "plain field in setTimeout" row is
the pattern to look for. NgZone.onStable/onMicrotaskEmpty stop firing without Zone.js,
so code using them must move to afterNextRender/afterEveryRender.
How It Actually Works¶
Every component view has a set of flags — "dirty", "refresh view", "has signal consumers that changed" — and the view tree mirrors the component tree.
Scheduling. Each notification in the list above calls into an internal
change-detection scheduler. The first notification schedules a single tick() (racing
setTimeout against requestAnimationFrame, so several notifications in the same turn
coalesce into one pass); later notifications before it runs are no-ops. That's why ten set() calls
cause one render, and why the DOM doesn't update synchronously in the click handler — we
had to wait before reading it in the test.
Traversal. ApplicationRef.tick() walks from the root view. For each component view
it decides:
Eager→ refresh this view's bindings, then visit children.OnPushand marked dirty → refresh, then visit children.OnPush, not dirty, but a descendant had a signal change → skip this view's own bindings and continue down to the descendant (signal changes mark the path to the root for traversal only, not for refreshing every ancestor).- otherwise → skip the whole subtree.
Refreshing a view means running its template function in update mode (Level 1,
lesson 01), which compares each bound value with the stored previous value. Setting a
component input during that parent refresh is what marks an OnPush child dirty — but only
when Object.is(previous, next) is false, which is why mutation was invisible to it.
Development checks. In dev mode, after each tick() Angular runs a second
"checkNoChanges" pass and throws NG0100: ExpressionChangedAfterItHasBeenCheckedError
if a binding produced a different value the second time — a sign that rendering itself
changed state. By default that pass skips OnPush views that aren't dirty — which, in
Angular 22, is most views. We bound {{ random() }} (returning Math.random()) and got:
OnPush (default) no error
OnPush + provideCheckNoChangesConfig({ exhaustive: true }) NG0100: ExpressionChangedAfterItHasBeenCheckedError
Eager NG0100: ExpressionChangedAfterItHasBeenCheckedError
Add provideCheckNoChangesConfig({ exhaustive: true }) to your development or test
configuration to catch these bugs in OnPush components too.
The scheduler detail above comes from the framework source: in Angular 22 the zoneless
scheduler races a setTimeout against a requestAnimationFrame and runs the tick on
whichever fires first.
Common mistakes¶
- Relying on async callbacks to update plain fields in a zoneless app.
- Mutating inputs and expecting OnPush children to notice.
- Calling
detectChanges()everywhere to "fix" rendering. It hides the real problem and can run far more often than needed. Prefer signals, thenmarkForCheck(). - Making everything
Eagerafter an upgrade instead of fixing the few mutation sites. - Using
NgZone.run/onStablein new code; they do nothing useful without Zone.js.
Exercise¶
- Reproduce the lab: an OnPush component with a plain
countfield incremented insetIntervalevery second. Confirm the screen doesn't change. Fix it three ways —markForCheck(), a signal, andtoSignal(interval(1000))— and explain which you'd ship. - Give a child component an array input and push to the array from the parent. Show that the child doesn't update; fix it by replacing the array.
- Add
changeDetection: ChangeDetectionStrategy.Eagerto the child and observe the difference. Write down why this is a worse fix than replacing the array. - Trigger
NG0100on purpose: bind{{ random() }}whererandom()returnsMath.random()in a dev build. Confirm there's no error with default OnPush, then addprovideCheckNoChangesConfig({ exhaustive: true })and read the error.