Change Detection in Angular 22: Eager, OnPush by default, and how Zoneless fits in
You upgrade to Angular 22, run ng update, and the diff comes with a surprise: changeDetection: ChangeDetectionStrategy.Eager added to components that never declared it. That line marks the moment two framework rules change at once: Default is renamed to Eager and deprecated, and OnPush becomes the default strategy after a decade.
We'll look at what each strategy really does, why the Angular team flipped the default, how they behave in a zoneless app (the default since v21), and what I would do in production this week. With examples ready to copy.
From Default to Eager: the rename nobody saw coming
For years there were two change detection strategies and we all knew them by name: Default (checks the component whenever the traversal reaches it) and OnPush (checks only under certain conditions). In Angular 22, Default was renamed to Eager and left as a deprecated alias, waiting to be removed:
// change-detection.constants.ts (simplified)
enum ChangeDetectionStrategy {
OnPush,
Eager,
Default, // @deprecated: equivalent to Eager, will be removed in a future version
}
"Use the Eager strategy, meaning that the component is checked eagerly when the change detection traversal reaches it, rather than only checking under certain circumstances (e.g. markForCheck, a signal in the template changed, etc)." - Source: ChangeDetectionStrategy - angular.dev
The new name describes behavior, not position. Default was always a lazy label: it said which option was the default, but not what it did. Eager tells the truth: the component gets checked eagerly, even when nobody announced a change.
The v22 migration does two things when you run ng update: it adds an explicit changeDetection: ChangeDetectionStrategy.Eager to every component that didn't declare a strategy (so nothing changes after the default flips), and it replaces Default with Eager wherever it still appears in decorators. - Source: Ninja Squad - Angular 22.0
Keep the list of components that received an explicit Eager. It is literally the map of your change detection debt, and we'll use it at the end.
OnPush by default since v22: what actually changes
"Until Angular v21, the default strategy was Eager. Since Angular v22, the default strategy is now OnPush!" - Source: Ninja Squad - Angular 22.0
With OnPush as the default, any component without a declared strategy now skips checking unless something justifies it. The documented cases where OnPush does run change detection:
- It receives new inputs as the result of a template binding (Angular compares references with
==). - An event is handled inside its subtree: event binding, output, or
@HostListeneron the root component or any of its children. - Someone calls
markForCheck()on it (theAsyncPipedoes it automatically). - A signal read in its template changes (the star notification of the zoneless era).
The concrete scenarios, straight from the docs: if the event happens in an Eager component, Angular walks the whole tree but skips OnPush subtrees that didn't receive new inputs. If the event happens inside an OnPush component, its subtree and its ancestors get checked (the event marks everything above it as dirty). And if a parent passes a new input to an OnPush child, only that subtree runs. - Source: Skipping component subtrees - angular.dev
And the two edge cases that will bite someone on your team:
// dashboard-widget.ts
export class DashboardWidget {
// ❌ Mutating the input by reference doesn't trigger change detection on OnPush:
// same reference, and the `==` comparison catches nothing
refresh(filters: Filters) {
this.filters.apply(filters);
}
// ✅ If you really must mutate, you do the notifying
refreshNotifying(filters: Filters) {
this.filters.apply(filters);
this.cd.markForCheck();
}
}
If something "doesn't refresh" after the migration, before blaming Angular 22 check who stopped notifying: that stale binding almost always belongs to a component that relied on Zone.js to hear about everything. That call no longer exists, and the component has no way to know something changed.
Eager vs OnPush: benefits, costs, and when to use each
Eager is comfort: zero discipline. It works with code that mutates objects by reference, with third-party components that notify nothing, and with those legacy modules nobody wants to touch. You pay the cost on every event: Angular checks the entire tree even when 90% of the components changed nothing. On a screen with hundreds of components, a good chunk of your 16.6ms budget (the 60fps one) goes into checking the same thing over and over.
OnPush is the discipline that is now free: the framework skips whole subtrees without you writing anything, and it fits naturally with signals (a signal() read in the template notifies by itself). The cost is the old-binding trap: silent mutations, reactive forms that change without refreshing, and the temptation to "fix it" by setting Eager and throwing away the whole optimization.
Eager (ex Default) | OnPush | |
|---|---|---|
| Status in v22 | Available, deprecated under the name Default | Default since v22 |
| When it's checked | Every time the traversal reaches the component | Only with new inputs, events in its subtree, markForCheck(), or template signals |
| Requirements | None | Notify changes (signals, immutability, markForCheck()) |
| Risk | Performance: over-checks on every event | Stale UI if something mutates without notifying |
| Ideal for | Hosts of third-party components, legacy islands | Everything else, especially with signals |
When should you keep Eager on purpose? The documented case is a component that acts as a host for user components created dynamically (via ViewContainerRef.createComponent, for example): if you set OnPush on the host and the child isn't OnPush-compatible, the child stops refreshing. In a library that doesn't control what gets attached below it, Eager is the honest option. - Source: Zoneless - angular.dev
For everything else: OnPush. It's the baseline now, and fighting the default costs you extra work on every new component.
Change Detection in Zoneless: the new rules of the game
Since v21, Angular apps are zoneless by default. Without Zone.js there's no guard shouting "check everything" on every setTimeout or DOM event. Angular now reacts only to explicit notifications: a signal read in a template that changed, a markForCheck(), a ComponentRef.setInput(), the callback of a bound listener, or attaching a view marked as dirty. Nothing else. - Source: Zoneless - angular.dev
The zoneless notification list and the OnPush conditions are almost the same list. An "OnPush-compatible" component is, de facto, a zoneless-compatible component. That's why the default flip to OnPush and the zoneless era are the same play in two moves: first the framework forces you to notify properly, then it removes the safety net that forgave you for not doing it.
That said, there are three places where the absence of Zone.js shows:
Reactive forms. Model updates (setValue, patchValue, FormArray.push) update form state and emit observables, but they don't schedule change detection. If your template depends on that state, wire it up:
// profile-form.ts
import { ChangeDetectorRef, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
export class ProfileForm {
private readonly cd = inject(ChangeDetectorRef);
constructor() {
this.form.valueChanges
.pipe(takeUntilDestroyed())
.subscribe(() => this.cd.markForCheck());
}
}
"Reactive forms model updates (setValue, patchValue, FormArray.push, and similar APIs) update form state and emit form observables, but they do not automatically schedule component change detection." - Source: Zoneless - angular.dev
The modern alternative: toSignal(this.form.valueChanges) and read the signal in the template, and you forget about manual markForCheck calls.
NgZone observables. onMicrotaskEmpty, onUnstable, and onStable never emit in zoneless, and isStable is always true. If you used them to wait for Angular to finish, the replacement is afterNextRender (one pass) or afterEveryRender (conditions spanning several passes).
Testing. The CLI now generates tests with await fixture.whenStable() instead of fixture.detectChanges(), and rightly so: detectChanges() forces checks Angular never scheduled, so your tests stop testing real behavior. To catch bindings updated without a notification, provideCheckNoChangesConfig({ exhaustive: true, interval: 1000 }) in development casts the net and throws ExpressionChangedAfterItHasBeenCheckedError the moment someone mutates in secret.
If you want the full migration walkthrough (what to look for, what to remove, how to deal with Zone.js-dependent libraries), the definitive Zoneless guide breaks it down step by step. And to measure the result, the DevTools Change Detection analyzer that shipped in v22.2 (explained in the Angular 22.2 post) paints any component over 16.6ms per pass in red.
What I would do in production this week
- Run
ng updateand read the diff. The list of components that receivedchangeDetection: ChangeDetectionStrategy.Eageris your debt map: each one is a component that today depends on automatic checking. - Don't add
Eagerto put out fires. If a binding doesn't refresh, the fix is notifying: signals ormarkForCheck(). SettingEagerrestores a 2015-style performance bug. - One week with
provideCheckNoChangesConfig({ exhaustive: true })in development. It will catch every silent mutation before a user does. - Reactive forms before zoneless. Connect
valueChangestomarkForCheck()or move form state to signals withtoSignal(). Forms are the number one place where the UI goes stale without Zone.js. - Measure with the Change Detection analyzer. Sort components by last-pass time and look for anyone over 16.6ms. Refactor candidates show up on their own.
Already on v22? Did OnPush break anything, or did your app not even notice the flip? Tell me in the comments.