Angular 22 OnPush by Default: What Changed and How to Migrate [2026]

Link copied
Angular 22 OnPush by Default: What Changed and How to Migrate [2026]

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:

  1. OnPush is the default. A component that doesn't set changeDetection behaves as OnPush, and ng generate component produces OnPush components.
  2. Default is now called Eager. Same behaviour, clearer name: it checks eagerly, every time.
  3. ng update protects existing apps. The migration adds changeDetection: ChangeDetectionStrategy.Eager to every component that didn't specify a strategy, and rewrites ChangeDetectionStrategy.Default to Eager. 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:

  1. Start at the leaves. Presentational components with inputs and outputs are usually OnPush-ready already.
  2. Look for mutation. Search the component and its parents for push(, splice(, sort( and direct property writes on objects passed as inputs.
  3. Look for async writes. Any field assigned in subscribe, setTimeout, promises or event-listener callbacks should become a signal, or be read through the async pipe.
  4. Remove the Eager line (or set OnPush explicitly) and exercise the component: click through its states, wait for timers and data loads.
  5. 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 ngModel on 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.

YouAfter upgrading to Angular 22, every component in src/app/dashboard has changeDetection: Eager. Which ones can safely switch to OnPush?
Claude · used 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

View all Angular articles →
Angular

When Angular is launched ?

Link copied Angular When Angular is launched ? February 8, 2024 · 1 min read When was Angular launched? # Angu…

Feb 8, 2024 Read →

Enjoyed this article?

Get new Angular tutorials delivered. No spam — just code-first articles when they ship.

Leave a Comment

Your email stays private. Required fields are marked *

Leave a Comment

Your email stays private. Required fields are marked *