Angular 22 OnPush by Default: What Changed and How to Migrate [2026]
For most of Angular's history, every component was checked on every change detection pass unless you opted out with ChangeDetectionStrategy.OnPush. Angular 22 flipped that: OnPush is now the default, and the old behaviour has a new name, ChangeDetectionStrategy.Eager. For new code that's a free performance win. For code written the old way, it's a change that can make a component quietly stop updating, and knowing exactly why is the difference between a five-minute fix and an afternoon of confusion.
This is lesson 8.1 of the Angular Tutorial and the start of Module 8, CSR performance. It builds on zoneless change detection (lesson 3.5), which became the default in Angular 21. By the end you'll know what changed in v22, exactly what makes an OnPush component re-render, why a mutated array or a field set in a callback doesn't show up, and how to move an existing component from Eager to OnPush safely.
The two strategies #
Eager (the old "Default") |
OnPush (the new default) |
|
|---|---|---|
| Checked when | On every change detection pass that reaches it | Only when something marks it dirty |
| Cost | Grows with the whole tree | Grows with what actually changed |
| Forgiving of mutation | Yes: any change shows up eventually | No: a mutated object with the same reference is invisible |
| Fits | Legacy code that mutates state freely | Signals, immutable data, zoneless apps |
Change detection is Angular walking the component tree and updating the DOM where template values changed. Eager components are always included in that walk. OnPush components are skipped, along with their whole subtree, unless Angular has a reason to check them.
What changed in Angular 22 #
Three things:
- OnPush is the default. A component that doesn't set
changeDetectionbehaves as OnPush, andng generate componentproduces OnPush components. Defaultis now calledEager. Same behaviour, clearer name: it checks eagerly, every time.ng updateprotects existing apps. The migration addschangeDetection: ChangeDetectionStrategy.Eagerto every component that didn't specify a strategy, and rewritesChangeDetectionStrategy.DefaulttoEager. After upgrading, your app behaves exactly as before.
So nothing breaks on upgrade. The risk is in new components written with old habits, and in removing those Eager markers later without checking what the component relies on.
// What ng update leaves on an existing component
@Component({
selector: 'app-order-list',
templateUrl: './order-list.html',
changeDetection: ChangeDetectionStrategy.Eager, // added by the migration
})
export class OrderList { /* ... */ }
// A new component in v22: no line needed, it's OnPush
@Component({ selector: 'app-order-card', templateUrl: './order-card.html' })
export class OrderCard { /* ... */ }
What marks an OnPush component for checking #
An OnPush component is re-rendered when one of these happens:
| Trigger | Example |
|---|---|
| An input gets a new value (compared by reference) | Parent passes a new array, not the same array with a new item |
| A signal read in its template changes | count.set(5) where the template reads count() |
| An event is handled in it or its children | A (click) handler, an output() from a child |
The async pipe receives a value |
items$ | async emits |
You call markForCheck() |
Manual escape hatch via ChangeDetectorRef |
Signals are the reason OnPush is now a sensible default. A signal read in a template registers that component as a consumer; when the signal changes, Angular marks exactly that component (and its ancestors) for checking. Combined with zoneless scheduling, a signal write updates only the part of the page that uses it.
OnPush and zoneless: two halves of one model #
Zoneless (the default since v21) and OnPush (the default since v22) answer two different questions:
| Question | Answered by |
|---|---|
| When does change detection run at all? | Zoneless scheduling: after signal writes, handled template events, markForCheck(), the async pipe and a few framework notifications, instead of after every async task zone.js intercepted |
| Which components does it check? | OnPush: only the ones marked dirty and their ancestors, instead of the whole tree |
With both defaults on, a signal write schedules one change detection pass, and that pass visits only the components that read the signal (plus the path from the root down to them). That's why the old habits break together: a field set in a setTimeout neither schedules a pass (no zone.js) nor marks its component dirty (OnPush). The single fix, putting the value in a signal, solves both at once.
It also explains why Eager components cost more in a zoneless app than they used to. They don't cause extra passes, but every pass that does run walks through them and their whole subtree. One Eager component near the root of a big page can undo much of the benefit of OnPush everywhere below it.
Why components "stop updating" #
Almost every OnPush bug is one of two patterns.
1. Mutating an input instead of replacing it.
// Parent
protected items = [{ id: 1, name: 'Keyboard' }];
add() {
this.items.push({ id: 2, name: 'Mouse' }); // same array reference
}
<app-item-list [items]="items" />
ItemList is OnPush. Its input still points at the same array, so Angular sees no change and skips it: the new item never appears. The fix is to replace, not mutate, or better, use a signal:
protected items = signal([{ id: 1, name: 'Keyboard' }]);
add() {
this.items.update(list => [...list, { id: 2, name: 'Mouse' }]); // new reference
}
<app-item-list [items]="items()" />
2. Setting a plain field outside an event.
export class Clock {
protected time = new Date();
constructor() {
setInterval(() => (this.time = new Date()), 1000); // nothing marks the component
}
}
The timer changes a field, but no input changed, no event fired in the component and no signal was written, so the template never updates. Under the old Eager strategy with zone.js, the timer would have triggered a global check and hidden the problem. The fix is the same pattern as before:
export class Clock {
protected time = signal(new Date());
constructor() {
const id = setInterval(() => this.time.set(new Date()), 1000);
inject(DestroyRef).onDestroy(() => clearInterval(id));
}
}
The same applies to values set inside subscribe(), fetch().then(), WebSocket handlers and third-party library callbacks. If the value lives in a signal (or reaches the template through the async pipe), OnPush just works.
Moving an existing component from Eager to OnPush #
After upgrading, you'll have Eager markers everywhere. Removing them is where the performance benefit comes from, and it's safe if you go one component at a time:
- Start at the leaves. Presentational components with inputs and outputs are usually OnPush-ready already.
- Look for mutation. Search the component and its parents for
push(,splice(,sort(and direct property writes on objects passed as inputs. - Look for async writes. Any field assigned in
subscribe,setTimeout, promises or event-listener callbacks should become a signal, or be read through theasyncpipe. - Remove the
Eagerline (or setOnPushexplicitly) and exercise the component: click through its states, wait for timers and data loads. - Keep
markForCheck()as a last resort for code you can't change yet, such as a callback from a legacy library.
The Angular CLI's MCP server has a tool built for exactly this analysis, onpush_zoneless_migration, covered in lesson 12.3. It plans the change for a component or folder; you review and apply it.
Should anything stay Eager? #
A few cases justify keeping Eager for now:
- Components that wrap a mutation-heavy third-party library you can't easily adapt.
- Large legacy forms driven by
ngModelon mutable objects, until you migrate them to Signal Forms. - Code on its way out. Don't spend time converting a component you're about to delete.
Treat each remaining Eager as a small piece of tech debt with a reason next to it, rather than a default.
Common gotchas #
| Symptom | Cause | Fix |
|---|---|---|
| New list item doesn't render | Array mutated with push(); input reference unchanged |
Replace the array, or hold it in a signal and use update() |
Value set in subscribe() never shows |
No trigger marks the OnPush component | Use toSignal() / a signal, or the async pipe |
| Timer or WebSocket updates ignored | Field written outside any event | Write to a signal instead |
| Child updates only after you click something | Click event finally marks the tree dirty; the real change never did | Find the missing trigger; don't rely on incidental events |
Everything worked before upgrading, breaks after deleting Eager |
Component relied on eager checks | Revert that one component and apply the migration checklist |
@ViewChild writes to a child input don't show |
Setting properties imperatively bypasses input change detection | Pass it as a template input, or call markForCheck() on the child |
What's next #
Lesson 8.2 goes deeper into OnPush: how the dirty flag propagates up and down the tree, where it interacts badly with ngDoCheck and content projection, and how to measure the difference with Angular DevTools' profiler. After that, 8.3 fixes the other classic re-render problem: @for without a good track expression.
Try it yourself #
Generate a new component in a v22 project and confirm it has no changeDetection line. Pass it an array input, then add an item from the parent with push() and watch nothing happen. Switch the parent to a signal with update() and watch it work. Then pick one Eager component in a real app and run it through the migration checklist above.
onpush_zoneless_migrationOf the 9 components in dashboard, 6 can switch as they are: StatCard, TrendBadge, ChartLegend, DateRangePicker, EmptyState and SectionHeader only use inputs, outputs and signals. Three need changes first: ActivityFeed assigns this.events inside a WebSocket subscribe() (convert it to toSignal()), KpiGrid calls this.kpis.push() on an array passed to StatCard (replace the array instead), and RefreshTimer sets a plain field in setInterval (use a signal). I’d switch the six first, check the dashboard renders, then fix the three one at a time.Up next in Angular
More from this topic
Enjoyed this article?
Get new Angular tutorials delivered. No spam — just code-first articles when they ship.


