Change detection is the process by which Angular updates the DOM when the data changes. Two big changes in recent versions change how you work: in v21 new applications are zoneless (no Zone.js), and in v22 components are OnPush by default. Together this means: Angular no longer “guesses” when something changed — you have to tell it, and signals do that automatically.
- v21: new projects no longer include
zone.js. Change detection is scheduled by signals, events,markForCheck. - v22: components without
changeDetectionareOnPush.ChangeDetectionStrategy.DefaultbecomesEager(Default is deprecated). - ng update to v22 adds
Eagerautomatically where needed, so nothing breaks.
OnPush by default and Eager
// v22: no `changeDetection` → OnPush
@Component({ selector: 'app-user-card', template: `...` })
export class UserCard {}
// Do you want the old behaviour (“always check me”)? Explicitly:
@Component({
selector: 'app-legacy-widget',
changeDetection: ChangeDetectionStrategy.Eager, // the former Default (Default is deprecated)
template: `...`,
})
export class LegacyWidget {}What triggers a render (zoneless)
What schedules change detection in a zoneless application:
✓ a signal read in the template changes (signal.set/update, input(), resource, toSignal…)
✓ an event listener in the template or host: (click), (input), host: { '(keydown)': … }
✓ the async pipe receives a new value
✓ ChangeDetectorRef.markForCheck()
✓ ComponentRef.setInput(), attaching / detaching views
✓ the router: navigations
✗ setTimeout / setInterval / Promise.then that change a plain field
✗ subscribe() that sets a plain field
✗ callbacks from third-party libraries (WebSocket, Firebase, Maps) that set plain fields// ✗ Works with Zone.js, does NOT work zoneless + OnPush
export class Clock {
now = new Date();
constructor() {
setInterval(() => (this.now = new Date()), 1000);
}
}
// ✓ The idiomatic solution: a signal
export class Clock {
readonly now = signal(new Date());
constructor() {
const id = setInterval(() => this.now.set(new Date()), 1000);
inject(DestroyRef).onDestroy(() => clearInterval(id));
}
}
// ✓ For Observables: toSignal (or the async pipe)
readonly user = toSignal(this.userService.user$);
// Emergency solution for old code: markForCheck
this.socket.on('msg', (m) => {
this.messages.push(m);
this.cdr.markForCheck();
});The OnPush rules
An OnPush component is checked only when:
1. one of its input() receives a NEW reference (compared with ===)
2. an event happens in it or in its children
3. a signal read in its template changes
4. the async pipe in its template emits
5. someone calls markForCheck() on it (or on a descendant)
Otherwise, its whole subtree is skipped. That is why OnPush + signals = minimal renders.Mutations and OnPush
OnPush compares inputs by reference. If you mutate an object in place, the child doesn’t find out. Exactly the same rule as for signals: immutability.
ChangeDetectorRef: when you really need it
const cdr = inject(ChangeDetectorRef);
cdr.markForCheck(); // “check me in the next round” (asynchronous, recommended)
cdr.detectChanges(); // “check me NOW, synchronously” (rare: immediate measurements, tests)
cdr.detach(); // removes the component from change detection (dashboards with manual updates)
cdr.reattach();
// v22: cdr.checkNoChanges() was removed from the public API — in tests use fixture.detectChanges()Migrating an existing project
# ng update to v22 automatically adds ChangeDetectionStrategy.Eager to components
# that had no changeDetection set — the application’s behaviour stays identical.
ng update @angular/core@22 @angular/cli@22
# Then, component by component: remove Eager, move the state to signals, test.
# For zoneless on an existing project, Angular 21 added an analysis tool:
# onpush_zoneless_migration (through the Angular CLI MCP server: ng mcp)Signals are what tells Angular what to re-render: signal() and computed() and, in more depth, Signals under the hood. What makes an app slow, step by step, in Performance — a checklist.
When YES, when NO
- Any new component — it is already the default.
- State lives in signals, inputs, resources.
- Large lists, dashboards, pages with a lot of content.
Eager: old code, not yet migrated, which moves plain fields from callbacks.markForCheck: integration with third-party libraries that don’t know about signals (but it’s better to put the value in a signal).detectChanges: rarely — tests or measurements right after a change.
Common pitfalls
- “It only works after I click somewhere” = a plain value changed asynchronously. Move it to a signal.
- You mutate an object received through an input → the OnPush child doesn’t update. Create a new reference.
- Tests that relied on Zone.js (
fakeAsync, implicitwhenStable): zoneless, useawait fixture.whenStable()and Vitest’s fake timers. - Old libraries that assume Zone.js (e.g. ones waiting for
NgZone.onStable) — check compatibility before you drop zone.js.