OnPush Is the Default in Angular 22: What Breaks and What to Do

Angular 22 flipped the change detection default to OnPush. A component without an explicit changeDetection strategy is now OnPush, not eager, and ng update writes ChangeDetectionStrategy.Eager onto your existing components to keep them working. That preserves behavior, but it also hands you a precise list of the components worth fixing.

What changed, in one line

Before v22, a component that never set a changeDetection strategy ran eager: Angular checked it on every tick. In Angular 22 that same component is OnPush, which only re-renders when an input reference changes, an event fires inside it, an async pipe emits, or a signal it reads is written.

ng update does not break your app. It adds changeDetection: ChangeDetectionStrategy.Eager to your existing components so nothing changes at runtime. New components, though, start OnPush, and that is the right default. The interesting work is deciding which of the migrated components actually needed eager checking, and which were just never told they could be OnPush.

What actually breaks

Remove the migration's Eager from a component and the failure is always the same shape: the data changed, but the view did not. OnPush does not check on every tick, so any state you mutate without telling Angular stays invisible.

The usual culprits are mutation in place (pushing to an array or setting a property on an object the template reads), a value set from a callback Angular does not know about (a third-party widget, a raw addEventListener, a setTimeout that writes a field), and a parent that mutates an @Input object instead of passing a new reference. None of these mark the view dirty, so OnPush never re-renders.

Why this stops rendering under OnPushts
@Component({ selector: 'app-cart', template: '{{ items.length }} items' })
export class CartComponent {
  items: Item[] = [];

  // Mutating in place does NOT mark an OnPush view dirty: the count is stale.
  add(item: Item) {
    this.items.push(item);
  }
}

The two ways to make it right

There is the escape hatch and the fix. The escape hatch is the Eager the migration already wrote: it keeps the component checking on every tick, which is fine as a stopgap but is the behavior you are trying to leave behind. The fix is to give the component a reason to re-render that OnPush understands.

The cleanest fix is signals, because writing a signal marks the OnPush view dirty for you. Where signals do not fit yet, a new reference on an @Input works, and as a last resort you inject ChangeDetectorRef and call markForCheck() after the change. Reach for markForCheck knowing it is a sign the state should probably be a signal.

The causeThe OnPush-clean fix
Mutating an array or object in placeWrite a signal (or assign a new reference)
A value set from a non-Angular callbackMove the value into a signal, or markForCheck()
A parent mutating an @Input objectPass a new reference from the parent
You genuinely need every-tick checkingKeep ChangeDetectionStrategy.Eager, on purpose
The same component, OnPush-cleants
@Component({ selector: 'app-cart', template: '{{ items().length }} items' })
export class CartComponent {
  readonly items = signal<Item[]>([]);

  // A signal write marks the OnPush view dirty. The count updates.
  add(item: Item) {
    this.items.update((list) => [...list, item]);
  }
}

How to find them, including in tests

You do not have to hunt. Every ChangeDetectionStrategy.Eager the migration added is a component that ran eager, and each is a candidate to make properly OnPush. Work that list by cost: the cheap ones become signals, the load-bearing ones keep Eager until you have time, and you delete the line when the component is clean.

Tests surface the same shape. An OnPush component does not render just because time passed or state changed; you call fixture.detectChanges() after the change to see it. That is the same discipline the Vitest timing tests need, and the same class of hidden dependency that an app trips over when it goes fully zoneless.

What I would do

Let new components be OnPush, because the v22 default is the right one. Treat the migration's Eager additions as a backlog, not a verdict: convert the easy ones to signals as you touch the code, and leave the rest with Eager until there is a reason to return. There is no campaign here, just a default that finally points the right way.

This is one slice of the Angular 22 upgrade. The OnPush default is the change that touches the most components, and it rewards the same habit the rest of modern Angular does: make state changes visible to the framework instead of hoping a tick catches them.

Reusable artifact

Making a component OnPush-clean

  • Find the ChangeDetectionStrategy.Eager lines ng update added: that is your backlog.
  • Replace in-place mutation with a signal write or a new reference.
  • For values set from non-Angular callbacks, move them into a signal or call markForCheck().
  • Make sure parents pass a new reference instead of mutating an @Input object.
  • In tests, call fixture.detectChanges() after a change so the OnPush view renders.
  • Delete the Eager line once the component re-renders without it; keep it only where every-tick checking is truly needed.

FAQ

Why did my Angular component stop updating after upgrading to v22?

Because OnPush is the default change detection strategy in Angular 22. A component without an explicit changeDetection strategy now only re-renders on an input reference change, an event, an async pipe emission, or a signal write. If you mutate state in place or set it from a callback Angular does not know about, the view will not update. Move the state into a signal, pass a new reference, or call markForCheck().

What does ChangeDetectionStrategy.Eager mean in Angular 22?

It is the old default behavior: the component is checked on every change detection tick. Since v22 makes OnPush the default, ng update adds ChangeDetectionStrategy.Eager to your existing components so their behavior does not change. It is a safe stopgap, and each one is a candidate to convert to OnPush.

Do I have to migrate all my components to OnPush?

No. The migration keeps them working by marking them Eager. You convert them at your own pace, usually by moving mutated state into signals, and you keep Eager on the few components that genuinely need every-tick checking.

Why does my OnPush component not update in a test?

An OnPush component re-renders only when something marks its view dirty. In a test, call fixture.detectChanges() after the change so the view reflects it. Advancing time or setting state alone does not render an OnPush component.

Sources checked

Modern Angular Playbook

This article is one play.

The Modern Angular Playbook collects the diagnostic, the adoption matrix, eleven plays, and the 30-day plan. Free, in English and Portuguese.

You get both PDFs by email, through the Dojo IA list.

Open playbook →

André Ramosavailable for remote roles, UTC−3Get in touch →

Related

Guide · 11 min

Angular 22: What Actually Changed, and What to Do in a Production App

A senior, production-first guide to Angular 22: the OnPush default, the stabilized Signal Forms and async-signal APIs, the testing migrations, the smaller default flips that bite on upgrade, and a deliberate plan for adopting it.

Read article →

Guide · 10 min

The Code That Breaks When Angular Goes Zoneless

A code-heavy review guide for timers, subscriptions, forms, third-party callbacks, and tests before a zoneless Angular spike.

Read article →

Note · 6 min

What to Adopt, Spike, or Wait On in Angular 21

An adoption filter for Angular 21: what belongs in code already changing, what deserves a branch, and what should stay away from critical flows.

Read article →