A practical, code-first tour of Angular 22's biggest change-detection shift — what breaks, what doesn't, and how to migrate without surprises*
Quick question before we get into it: when was the last time you added ChangeDetectionStrategy.OnPush to a component, ran the app, and then spent the next ten minutes wondering why a value on screen just... stopped updating?
If you've written Angular for more than a few months, that probably rings a bell. OnPush has always been the "fast lane" — fewer wasted checks, better performance — but it came with a tax. You had to opt in, understand exactly what triggers a re-render, and occasionally sprinkle markForCheck() around like seasoning when something didn't refresh the way you expected.
Angular 22 just changed that deal. As of this release, every component that doesn't explicitly set a change detection strategy now runs under OnPush automatically. The fast lane is now just... the lane.
This is part 1 of a series where we're going through Angular 22 feature by feature. This one is all about the change-detection shift specifically — what changed, why now, what can quietly break, and how to migrate without losing a weekend to it.
By the end, you'll know:
- What actually changes when
OnPushbecomes the default — and what doesn't - Why
ChangeDetectionStrategy.Defaultgot renamed toEager, and why that name makes more sense - The one scenario that can silently break your app after upgrading, with zero console errors
- Why signals and
OnPushare basically built for each other - Where this fits into Angular's bigger move toward zoneless apps
- A step-by-step
ng updatewalkthrough so you're not migrating blind
That's a lot of ground, but I promise it's more "oh, that makes sense" than "oh no." Let's get into it.
If you want the rest of this series as it drops — eleven more parts covering everything else in this release — following me here on Medium is the easiest way to not miss one.
Before we dive into the examples, a quick note: these snippets are meant purely for understanding the concepts in this release. Angular 22 only shipped on June 3, 2026, so a couple of the APIs we'll touch — especially the experimental
debounced()helper — may still see small signature tweaks in patch releases. Always check the official Angular documentation for the exact, current syntax before shipping any of this to production.
OnPush Becomes the Default Change Detection Strategy
Let's start with the headline change.
Through Angular 21, if you wrote a component and didn't specify a changeDetection property, Angular used what was then called the Default strategy: on every change-detection cycle, it walked the entire component subtree and checked every binding — whether or not anything inside had actually changed. It worked, but in large component trees, it meant a lot of checks that found nothing to update.
As of Angular 22, that's flipped. Any @Component without an explicit changeDetection property now uses ChangeDetectionStrategy.OnPush automatically. No decorator property, no extra import, nothing to opt into.
Here's what that looks like in practice:
import { Component, signal } from '@angular/core';
@Component({
selector: 'app-counter',
// No changeDetection property here -- this component is OnPush
// automatically in Angular 22.
template: `
<button (click)="increment()">Count: {{ count() }}</button>
`,
})
export class CounterComponent {
readonly count = signal(0);
increment() {
this.count.update(c => c + 1);
}
}
Nothing in that file mentions OnPush — and yet it behaves exactly like an OnPush component always has: Angular skips this subtree unless something inside explicitly says "something changed." Here, the signal read inside {{ count() }} and the (click) event binding both qualify, so the button updates correctly with zero extra ceremony.
If you've got a component that genuinely needs the old "check everything, every time" behavior — say, it wraps a third-party widget that mutates the DOM behind Angular's back — you can still opt into that explicitly:
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
selector: 'app-legacy-widget',
changeDetection: ChangeDetectionStrategy.Eager, // restores pre-v22 default behavior
template: `...`,
})
export class LegacyWidgetComponent {}
Which brings us to that name — Eager. Where did Default go?
ChangeDetectionStrategy.Default Is Now ChangeDetectionStrategy.Eager
This is a small change with a satisfying reason behind it.
ChangeDetectionStrategy.Default was deprecated in Angular 21.2 and replaced by ChangeDetectionStrategy.Eager in Angular 22. Both values represent exactly the behavior they always did — check the whole subtree, every time — but the new name describes what the strategy does, instead of describing its position in a list.
Think about it from the framework's point of view: once OnPush became the default, calling the other option "Default" stopped making sense. Default... compared to what? The Angular team's own discussion on this rename put it nicely: Default used to mean "run change detection on me by default," not "I am the default strategy" — and that mismatch was confusing enough to be worth fixing.
So now you have two clearly named strategies:
-
OnPush— skip this subtree unless something explicitly marks it dirty (the new default) -
Eager— check this subtree on every CD run, no matter what (the old default, now opt-in)
If your codebase had components explicitly setting ChangeDetectionStrategy.Default, ng update rewrites them for you:
// Before Angular 22
@Component({
selector: 'app-old',
changeDetection: ChangeDetectionStrategy.Default,
template: `...`,
})
export class OldComponent {}
// After ng update to Angular 22 -- same runtime behavior, new name
@Component({
selector: 'app-old',
changeDetection: ChangeDetectionStrategy.Eager,
template: `...`,
})
export class OldComponent {}
Here's the side-by-side that I think makes this click fastest:
Eager (formerly Default) |
OnPush (now the default) |
|
|---|---|---|
| Default through... | Angular 21 | Angular 22 onward |
| Subtree checked when | Every CD run, unconditionally | Only when something marks it dirty |
| What marks it dirty | Nothing needed — always checked | Signal reads in the template, async pipe emissions, event bindings, input changes, markForCheck()
|
| Best fit | Legacy code mutating plain fields directly | Signal-based, zoneless-ready components |
Once you see it laid out this way, the rename stops feeling cosmetic — it's Angular finally calling things what they actually are.
Now, here's the part I really want you to slow down for, because it's the one that can bite you with zero warning.
Quick gut check before we move on: have you ever upgraded a dependency and had some piece of UI just... stop updating, with nothing in the console to explain why? Hold that thought — the next section is exactly that scenario, and I'd genuinely like to know in the comments afterward whether it matches what you ran into.
Impact on Existing Apps: The Good News and the One Catch
Let's get the good news out of the way first.
When you run ng update to Angular 22, the migration:
- Adds
changeDetection: ChangeDetectionStrategy.Eagerto every component decorator that doesn't already specify a strategy — preserving your exact pre-upgrade behavior. - Renames any existing
ChangeDetectionStrategy.DefaulttoChangeDetectionStrategy.Eager. - Leaves any existing
ChangeDetectionStrategy.OnPushalone.
In other words: your own application code keeps working exactly as it did the moment before you upgraded. Angular doesn't silently flip your components to OnPush behind your back — it explicitly tags them as Eager so nothing changes at runtime. That's the whole point of the migration.
So where's the catch?
ng update can only rewrite the source files inside your project. It has no way to touch code that's already compiled and sitting inside node_modules.
Picture a third-party library — say, a price-ticker widget — that ships a component with no explicit changeDetection, and updates its template by writing to a plain class property inside an RxJS subscription:
// A library component published before Angular 22.
// No explicit changeDetection -- pre-v22 this ran "check everything"
// and worked fine.
@Component({
selector: 'price-ticker',
// No changeDetection set here.
template: `<span>Latest price: {{ price }}</span>`,
})
export class PriceTickerComponent implements OnInit {
price = 0;
constructor(private feed: PriceFeedService) {}
ngOnInit() {
this.feed.prices$.subscribe(p => {
this.price = p; // plain field write -- Angular is never told to re-check
});
}
}
Before Angular 22, this worked because the component ran under Eager (then called Default) — Angular checked it on every cycle regardless. After you upgrade your app to Angular 22, that compiled library code still has no changeDetection property... which now means OnPush.
The result: the price stops updating on screen. No error. No console warning. No exception. The data is still arriving — prices$ is still emitting — but Angular was never told this component needs re-checking, so the template binding just freezes. If that same library does something imperative, like drawing onto a <canvas>, that keeps working fine. It's specifically Angular's template bindings — {{ }}, property bindings — that stop refreshing.
This is the single most important thing to test after upgrading: any third-party components that display live, push-based data.
If you hit this, you've got two options. The quick one, if you're still on zone.js:
export class PriceTickerComponent implements OnInit {
price = 0;
private cdr = inject(ChangeDetectorRef);
constructor(private feed: PriceFeedService) {}
ngOnInit() {
this.feed.prices$.subscribe(p => {
this.price = p;
this.cdr.markForCheck(); // works with zone.js; does not help in zoneless apps
});
}
}
And the one that actually fixes it for good — bridge the observable into a signal:
import { Component, DestroyRef, inject } from '@angular/core';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
@Component({
selector: 'price-ticker',
template: `<span>Latest price: {{ price() }}</span>`,
})
export class PriceTickerComponent {
private feed = inject(PriceFeedService);
private destroyRef = inject(DestroyRef);
readonly price = toSignal(
this.feed.prices$.pipe(takeUntilDestroyed(this.destroyRef)),
{ initialValue: 0 },
);
}
toSignal() turns the observable into a signal, and writing to a signal that's read in the template automatically marks the component dirty. This works correctly under Eager or OnPush, with zone.js or without it — which is really the whole theme of this release.
A handful of other breaking changes landed in the same v22 release, most of them auto-migrated:
-
strictTemplatesis nowtrueby default intsconfig.json— the migration addsstrictTemplates: falseif you weren't using it, so your build doesn't suddenly fail on new type errors. - Optional chaining in templates now matches TypeScript semantics:
project?.authorreturnsundefined(notnull) when the chain breaks. The migration wraps affected expressions in$safeNavigationMigration(...)so you can review them on your own schedule. On the upside, the compiler can now correctly narrowproject?.authorinside an@if (project?.author)guard, so a lot of those defensive?.usages can eventually come out entirely. -
HttpClientnow defaults to the Fetch-based backend.withFetch()is deprecated and removed by the migration — or replaced withwithXhr()if you relied on upload-progress events, which Fetch doesn't support. -
provideClientHydration()now enables incremental hydration by default; the migration addswithNoIncrementalHydration()if you weren't previously opted in. - The
canMatchguard signature gains a mandatory third parameter,currentSnapshot— auto-migrated.paramsInheritanceStrategynow defaults to'always'— this one is not auto-migrated, so check your router config if your app relies on the old'emptyOnly'behavior. - The toolchain floor moved up: TypeScript 6 is required (5.9 and below are unsupported), and Node 20 support is dropped (Node 22+ required, Node 26 officially supported).
None of these are scary individually, but they're exactly the kind of thing that's easy to skim past in a changelog and then debug three weeks later. Worth a slow read through your ng update diff.
Signals and OnPush: Why They Were Basically Built for Each Other
Here's the conceptual thread that ties this whole release together.
Signals tell Angular what changed. OnPush tells Angular what to skip. Put those together and you get a complete fine-grained reactivity model — and that's really why making OnPush the default "completes" the signals story rather than being some unrelated cleanup item.
Under the old Eager strategy, this distinction was invisible, because everything got checked regardless of whether a signal changed. Under OnPush, it's the entire mechanism: when a signal that's read in a template updates, Angular marks that component dirty and schedules a CD run. If nothing marks it dirty, the subtree is skipped entirely. The signal is the dirty flag.
This is also why the Resource API graduating to stable in Angular 22 matters so much for this topic. resource, rxResource, and httpResource all move from experimental to stable in this release, and httpResource in particular is a genuinely pleasant way to fetch data reactively:
import { Component, signal } from '@angular/core';
import { httpResource } from '@angular/common/http';
import { CurrencyPipe } from '@angular/common';
interface Product {
id: number;
name: string;
price: number;
}
@Component({
selector: 'app-product-list',
imports: [CurrencyPipe],
template: `
@if (productsResource.isLoading()) {
<p>Loading products...</p>
}
@if (productsResource.error()) {
<p>Something went wrong loading products.</p>
} @else {
@for (product of productsResource.value(); track product.id) {
<div>{{ product.name }} -- {{ product.price | currency }}</div>
}
}
`,
})
export class ProductListComponent {
readonly category = signal('electronics');
// Re-fetches automatically whenever category() changes.
readonly productsResource = httpResource<Product[]>(
() => `/api/products?category=${this.category()}`,
{ defaultValue: [] },
);
}
Notice there's no changeDetection here either — it's OnPush by default, and it doesn't need to be anything else. productsResource.value(), .isLoading(), and .error() are all signals, so reading them in the template is exactly the "tell Angular what changed" signal that OnPush needs.
Angular 22 also adds an experimental debounced() helper, which is handy for search-as-you-type without hand-rolling RxJS debounceTime. One thing worth getting right here: debounced() takes a reactive function that reads a signal — not the signal itself — and it returns a Resource, not another signal:
import { Component, signal, debounced } from '@angular/core';
import { httpResource } from '@angular/common/http';
@Component({
selector: 'app-search',
template: `
<input (input)="query.set($any($event.target).value)" placeholder="Search products..." />
@if (debouncedQuery.isLoading()) {
<p>Waiting for you to stop typing...</p>
}
@for (result of results.value(); track result.id) {
<div>{{ result.name }}</div>
}
`,
})
export class SearchComponent {
readonly query = signal('');
// Settles 300ms after `query` stops changing.
// Note: debounced() takes a function that reads the signal, not the signal itself.
readonly debouncedQuery = debounced(() => this.query(), 300);
readonly results = httpResource<{ id: number; name: string }[]>(
() => `/api/search?q=${this.debouncedQuery.value()}`,
{ defaultValue: [] },
);
}
While the user is typing, debouncedQuery.isLoading() is true, and debouncedQuery.value() still holds the last settled value — so your results don't flicker on every keystroke, and httpResource only re-fires once things have actually stabilized. Since debounced() is still experimental in v22, double-check its exact signature against whatever patch version you've got installed before shipping it.
The Zoneless Future: Where This Fits in the Bigger Picture
If you've been keeping half an eye on Angular over the last couple of years, this release probably doesn't feel like it came out of nowhere — and it shouldn't. Here's the timeline:
-
Angular 18 introduced zoneless change detection as an experimental preview, behind
provideExperimentalZonelessChangeDetection. - Angular 19 refined the zoneless APIs and added SSR support.
-
Angular 20.2 promoted
provideZonelessChangeDetection()to stable. -
Angular 21 made zoneless the default for new applications —
ng newno longer wires upzone.jsor callsprovideZonelessChangeDetection()explicitly; it's simply the baseline. Existing apps keepzone.jsuntil they choose to remove it. -
Angular 22 makes
OnPushthe default change detection strategy — the piece that makes zoneless apps performant by default, not just functional.
Put together: "check everything via zone.js" is no longer the starting point for any new Angular app. Signals tell Angular what changed, OnPush skips everything that wasn't touched, and zoneless removes the layer that used to trigger a check on every async operation.
That last part is the one that trips people up, so let's make it concrete. In a zone.js app, this used to "just work":
// Worked in zone.js apps because zone.js patches setTimeout
// and triggers a CD run when the callback fires.
@Component({
selector: 'app-async-demo',
template: `<p>{{ count }}</p>`,
})
export class AsyncDemoComponent {
count = 0;
ngOnInit() {
setTimeout(() => {
this.count++; // in a zoneless/OnPush app, the template silently won't update
}, 1000);
}
}
In a zoneless (or just plain OnPush) app, nothing tells Angular that count changed — setTimeout isn't patched, so there's no automatic CD trigger. The fix is the same one we used for the price ticker: make the state a signal.
@Component({
selector: 'app-async-demo',
template: `<p>{{ count() }}</p>`,
})
export class AsyncDemoComponent {
count = signal(0);
ngOnInit() {
setTimeout(() => {
this.count.update(c => c + 1); // marks the view dirty -- re-renders correctly
}, 1000);
}
}
And if you want to go fully zoneless explicitly — still entirely valid, just no longer something a brand-new project needs to add by hand:
import { bootstrapApplication } from '@angular/platform-browser';
import {
provideZonelessChangeDetection,
provideBrowserGlobalErrorListeners,
} from '@angular/core';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, {
providers: [
provideZonelessChangeDetection(),
provideBrowserGlobalErrorListeners(),
],
});
The rule of thumb going forward: change detection runs when a signal read in a template updates, the async pipe gets a new value, a template event fires, or something explicitly calls markForCheck() / ApplicationRef.tick(). Anything outside those four — a raw timer, a manual subscription writing to a plain field, a third-party library mutating data behind Angular's back — needs to be bridged into a signal to participate in rendering.
Migration Considerations: The ng update Walkthrough
Let's turn all of this into a checklist you can actually run through.
Step 1 — confirm your toolchain meets the new minimums:
node -v # needs to be >= 22 (26 is supported)
npx tsc -v # needs to be >= 6.0
Step 2 — run the update. This applies every automatic migration we've covered: tagging unspecified components as Eager, renaming Default to Eager, the strictTemplates flag, the $safeNavigationMigration wrapper, the HTTP client changes, and the hydration flag.
ng update @angular/core@22 @angular/cli@22
Step 3 — build and run your full test suite. Most regressions from a change like this surface here, not in manual testing.
ng build
ng test
Step 4 — review what the migration actually touched, one concern at a time, instead of just accepting the diff:
grep -rn "ChangeDetectionStrategy.Eager" src/
grep -rn "\$safeNavigationMigration" src/
grep -rn "withNoIncrementalHydration\|withXhr" src/
That third command finds the optional-chaining wrappers and the HTTP/hydration flags — both things the migration added purely to preserve old behavior, and both good candidates to revisit once you've confirmed everything still works.
What ng update does not do for you:
- It won't touch third-party
node_modulespackages — that's the "silent freeze" scenario from earlier. Your options are waiting for the library to update, wrapping its data source yourself withtoSignal(), or pinning the dependency until you're ready to deal with it. - It won't change
paramsInheritanceStrategy— if your app depends on the pre-v22 default, set it to'emptyOnly'explicitly in your router config. - It won't convert your
Eagertagged components toOnPushfor you. That's a deliberate, per-component decision, and it usually starts with converting the component's state to signals.
That last point is worth walking through, because it's the actual "finish line" for each component. Here's one right after ng update — auto-migrated, behaving exactly as before:
// Right after ng update -- auto-generated, preserves old behavior.
@Component({
selector: 'app-profile',
changeDetection: ChangeDetectionStrategy.Eager,
template: `<p>{{ user?.name }}</p>`,
})
export class ProfileComponent implements OnInit {
user?: User;
constructor(private userService: UserService) {}
ngOnInit() {
this.userService.getUser().subscribe(u => this.user = u);
}
}
And here's the manual follow-up: convert the state to a signal, then delete the changeDetection line entirely.
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
// Manual follow-up -- state is now a signal, no changeDetection override needed.
@Component({
selector: 'app-profile',
template: `<p>{{ user()?.name }}</p>`,
})
export class ProfileComponent {
private userService = inject(UserService);
readonly user = toSignal(this.userService.getUser());
}
No replacement annotation needed — OnPush is the default. That component is done.
Unit Testing OnPush and Signal-Based Components
A question I hear a lot when people first work with OnPush: "does this break my tests?" Mostly, no — but there's one habit worth building.
With TestBed, fixture.detectChanges() works exactly the same under OnPush as it always has when called manually — it forces a check regardless of whether the component thinks it's dirty. The difference only shows up when you're testing that an update happens automatically, without you calling detectChanges() yourself — which is precisely where signals help.
Here's a test for the CounterComponent from earlier:
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CounterComponent } from './counter.component';
describe('CounterComponent', () => {
let fixture: ComponentFixture<CounterComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CounterComponent],
}).compileComponents();
fixture = TestBed.createComponent(CounterComponent);
fixture.detectChanges();
});
it('renders the initial count', () => {
const button: HTMLButtonElement = fixture.nativeElement.querySelector('button');
expect(button.textContent).toContain('Count: 0');
});
it('updates the displayed count when the signal changes', () => {
fixture.componentInstance.increment();
fixture.detectChanges();
const button: HTMLButtonElement = fixture.nativeElement.querySelector('button');
expect(button.textContent).toContain('Count: 1');
});
it('updates the count via a real click event', () => {
const button: HTMLButtonElement = fixture.nativeElement.querySelector('button');
button.click();
fixture.detectChanges();
expect(button.textContent).toContain('Count: 1');
});
});
A few things worth calling out:
- We call
fixture.detectChanges()after each state change — that's the test-environment equivalent of "Angular ran a CD cycle," and it's required regardless of strategy. - The third test fires a real DOM
click, exercising the(click)event binding directly. This is the kind of test that used to quietly pass or quietly fail depending on which strategy a component happened to use — and now behaves consistently, becauseOnPushis the strategy either way. - If you're testing a component built around
toSignal()and an observable (like ourPriceTickerComponent), the same pattern applies: push a value through your mock service's subject, callfixture.detectChanges(), and assert on the rendered output. The signal write marks the view dirty;detectChanges()flushes it in the test.
If you're migrating your test setup to Vitest as part of this upgrade — Angular 22 continues investing here, including better fakeAsync support for teams making that move gradually — the same testing pattern applies. Only the runner and assertion syntax change, not the OnPush-related behavior itself.
Bonus Tips
A few smaller things that didn't need their own section but are worth keeping in your back pocket:
- If a component "stops updating" after upgrading and you're not sure why, check whether it's coming from a third-party package first. The
node_modules"silent freeze" is by far the most common surprise from this release. - You don't have to convert everything to signals on day one. Letting
ng updatemark your existing components asEagerand migrating component-by-component, on your own schedule, is exactly what the migration was designed to support. - Angular DevTools' signal graph is genuinely useful here, even if you're not pairing it with an AI agent — it's one of the fastest ways to see why a component did or didn't update.
- When you do start converting components, prioritize the ones that already read from observables via
asyncor manual subscriptions. Those are the most likely to have a "plain field write" hiding inside them, andtoSignal()is almost always the cleanest fix.
Have you hit the "silent freeze" scenario yet, either during a test upgrade or in production? I'd genuinely love to know which library it was — drop it in the comments, it might save someone else a debugging session.
Recap
Here's the whole picture in one pass.
Angular 22 makes OnPush the default change detection strategy for any component that doesn't say otherwise. The old "check everything" behavior didn't disappear — it got renamed from Default to Eager, a name that actually describes what it does. ng update automatically tags your existing components as Eager so nothing changes at runtime on upgrade day, but it can't touch compiled code in node_modules — which is where the one real risk lives: third-party components with no explicit strategy and plain-field updates inside subscriptions can silently stop refreshing. The fix is always the same: bridge state into a signal with toSignal(), because a signal read in a template is exactly the "something changed" notification that OnPush — and zoneless apps — are built around. This release is also where the Resource API (resource, rxResource, httpResource) goes stable, and where the experimental debounced() helper shows up for search-as-you-type patterns.
If you take away one thing: go find the components that update via raw subscriptions or setTimeout, and start converting their state to signals. That single habit covers most of what this release changes for you.
What's your plan for rolling this out — run ng update and handle issues as they surface, or do a slower, component-by-component signal migration first? Tell me which one and why — I'm curious whether teams are converging on one approach.
What did you think?
Did this approach match how you're solving it, or do you have a different take? Drop a comment — I genuinely read every single one.
Found this helpful?
If this saved you even a few minutes of debugging or confusion, hit that clap button so others can find it too — and if you've got a teammate who's about to upgrade to Angular 22, send this their way before they run into the silent-freeze surprise firsthand.
Want more tips like this?
I share one practical dev insight every week. Follow me here on Medium or subscribe to my newsletter so you never miss one.
Let's connect — find me on LinkedIn or GitHub and let's keep the conversation going.
Follow Me for More Angular & Frontend Goodness:
I regularly share hands-on tutorials, clean code tips, scalable frontend architecture, and real-world problem-solving guides.
- 💼 LinkedIn — Let’s connect professionally
- 🎥 Threads — Short-form frontend insights
- 🐦 X (Twitter) — Developer banter + code snippets
- 👥 BlueSky — Stay up to date on frontend trends
- 🌟 GitHub Projects — Explore code in action
- 🌐 Website — Everything in one place
- 📚 Medium Blog — Long-form content and deep-dives
- 💬 Dev Blog — Free Long-form content and deep-dives
- ✉️ Substack — Weekly frontend stories & curated resources
- 🧩 Portfolio — Projects, talks, and recognitions
- ✍️ Hashnode — Developer blog posts & tech discussions
- ✍️ Reddit — Developer blog posts & tech discussions
Top comments (0)