Angular 22, signals and the zoneless mode
Angular is Google's framework for building browser applications, with dependency injection, routing, forms, and an HTTP client in one package. The current version is 22, under the MIT licence.
This release closes a transformation that ran for several years, and does so clearly enough that material from a year ago describes what is in practice a different framework. New projects start without Zone.js, and change detection rests on signals.
What version twenty two changed
Three things, in order of importance.
Zoneless mode has been the default in new projects since version 21, and twenty two keeps that decision. Zone.js is no longer included, and view updates follow from signals rather than from intercepting every browser event. The bundle is smaller, the first render faster, and the mental model simpler.
OnPush is now the default change detection strategy for a component. A component with no strategy set explicitly is checked only when one of its inputs changes through a template binding, when one of its own event listeners runs, or when something marks it for check. The previous behaviour returns by setting the Eager strategy, yet this is a breaking change and it deserves to be the first thing checked when updating an older application.
Signals are now the default way of holding state in a component rather than an addition beside the familiar way of working: component inputs and view queries have been stable since version 19. This release adds signal based forms to that list, with their interface moved into the public API. The stream based approach still works, while the new one removes a good share of the code that forms used to demand as ritual.
Alongside that, a smaller but noticeable change: the default testing tool in new projects is Vitest, and the experimental Jest and Web Test Runner builders were removed in this release. The framework has shipped no end to end layer since Protractor was retired, so that part comes from outside, usually Playwright or Cypress, and ng e2e asks which provider to install.
What Zone.js actually did and why its absence matters
Worth understanding, since without it judging whether migration pays is hard.
For years Angular refreshed the view in a way that looked like magic from outside. Zone.js patched browser functions: event handling, timers, network requests. After every such operation the framework knew something might have changed, and checked the whole component tree.
The advantage was enormous: you wrote ordinary code and the view updated itself. So was the drawback: the framework did not know what changed, only that something might have, so it checked everything. In a large application that meant hundreds of checks on every click.
A signal inverts that. A value wrapped in a signal knows who reads it, so changing it refreshes exactly the places depending on it. There is no whole tree check, since there is no need to guess.
The practical effect is that the framework stopped being exceptional in this respect and started behaving like other reactivity based solutions. Somebody arriving from React or Vue finds familiar concepts here rather than a separate world.
Signals in practice
import { Component, signal, computed } from '@angular/core'
@Component({
selector: 'app-cart',
template: `
<p>Items: {{ count() }}</p>
<p>Total: {{ total() }}</p>
<button (click)="add()">Add</button>
`,
})
export class CartComponent {
items = signal<Item[]>([])
count = computed(() => this.items().length)
total = computed(() => this.items().reduce((s, i) => s + i.price, 0))
add() {
this.items.update((i) => [...i, newItem()])
}
}Three things deserve noticing.
Reading a signal looks like calling a function, and that is deliberate: at the moment of reading, the framework records who depends on that value. Forgetting the parentheses in a template displays the function instead of the value and is the most common early slip.
A derived value computes itself and recomputes only when something it depends on changes. Holding the total as a separate signal updated by hand is a mistake, since it can then drift from the list.
Updating by supplying a function over the previous value is safer than setting a new one from a value read earlier, particularly with changes arriving in quick succession.
For reacting to a signal change with something that is not a derived value, writing to browser storage or emitting an event for instance, there is a separate mechanism.
import { effect, signal } from '@angular/core'
export class SettingsComponent {
theme = signal<'light' | 'dark'>('light')
constructor() {
effect(() => {
localStorage.setItem('theme', this.theme())
})
}
}Use it sparingly. An effect that sets another signal creates a loop hard to trace, and most of the things it tempts you to use it for are in fact derived values and belong in a computed.
Migrating an existing application
Here a sober assessment belongs, since the scale of work depends on how old the application is.
An application on modules with Zone.js based change detection will not stop working. Nobody is switching the old mode off, and the new version supports it. That means migration is a choice rather than a requirement, and can be spread out.
A sensible order looks like this. Standalone components first, since that change is mechanical and supported by automated tooling. Then built in template control flow instead of the old directives, again with tooling. Then signals for local component state, gradually, file by file. Turning Zone.js off last, since only then is it safe.
ng generate @angular/core:standalone
ng generate @angular/core:control-flow
ng generate @angular/core:signalsEach of those commands walks the repository and applies changes on its own, so run them one at a time, on a separate branch, and review the result before the next. On a large application the first can touch several hundred files at once.
That last step is where teams stumble. Disabling Zone.js in an application where some state still sits in ordinary class fields means the view stops refreshing in places nobody touched. The symptom misleads: the application runs, only the numbers on screen stay stale.
Practical advice: before disabling Zone.js, check that every piece of state affecting the view is either a signal or passes through the async pipe in a template. Any ordinary field changed in a callback is a place that will stop working.
export class OrdersComponent {
orders: Order[] = []
ngOnInit() {
this.api.fetch().subscribe((o) => {
this.orders = o
})
}
}That code works while Zone.js intercepts the subscription completing and forces a refresh. Once it is off, the list stays empty on screen even though the class field holds the correct value. The fix is short and comes down to turning the field into a signal.
export class OrdersComponent {
orders = signal<Order[]>([])
ngOnInit() {
this.api.fetch().subscribe((o) => {
this.orders.set(o)
})
}
}Dependency injection, the part that did not change
Amid all the noise about signals, it pays to say what stayed the same, since it remains this framework's strongest side.
Dependency injection works differently here from most browser solutions: a class declares what it needs and the framework supplies it at construction. There is no passing dependencies down through levels by hand and no container you build yourself.
Two practical effects show up over longer maintenance. Swapping an implementation for a test comes down to one line of configuration rather than reworking code. And: a service declared once is visible across the application without importing it by path, so moving a file breaks nothing.
A service's visibility scope is a design decision here rather than a detail. A service available application wide exists as one instance and lives as long as the application. A service provided at a component is created with it and dies with it, which suits state tied to a particular screen and leads to hard to find surprises with shared state.
Newer syntax lets you obtain a dependency through a function rather than a constructor, which simplifies inheritance and allows injection inside helper functions. Worth knowing, since older examples online show only the constructor form.
Forms and working with data
Forms are more elaborate here than in most solutions, and that is sometimes the deciding argument for business applications.
The stream based approach gave full control and plenty of code: field definitions, validation rules, change handling, binding to the view. On a form with thirty fields, conditional visibility, and rules across fields, that verbosity was a real cost.
The new signal based approach removes much of that ritual, since a field's state is a signal and validation rules are derived values. Field visibility depending on another field stops requiring a subscription and manual cleanup after it.
Note, though, that both approaches will coexist for a while, so one application can hold two ways of doing the same thing. During migration it makes sense to settle that new forms use the new approach while old ones get rewritten alongside other changes, rather than planning one sweep through all of them.
Server side validation is a separate matter. Rules described in a form concern the browser and do not replace a check on the backend, if only because a request can be sent bypassing the interface. That is obvious and still regularly skipped on forms that look thoroughly guarded.
Angular against the alternatives
| Option | Scope | Learning curve | Pick it when |
|---|---|---|---|
| Angular | A framework with everything included | Steep | A large team, a long horizon, clear rules |
| React | A view library | Gentle at the start | Flexibility, the largest job market |
| Vue | A middle ground framework | The gentlest | A smaller team, a fast start |
| Next.js | A framework on React | Medium | Content sites that must be indexed |
The main argument for the first row is organisational rather than technical. The framework imposes structure: where logic lives, how dependency injection looks, how forms get built, how testing works. With a team of twenty and an application maintained for five years, that uniformity is worth more than flexibility.
The argument against is equally simple. With a team of three and an application due in a quarter, the same rule set is a cost. Add to that ecosystem size and the job market, where the first alternative holds an advantage hard to dispute.
Note too that the gap between this framework and the rest narrowed through precisely the change described here. Signal based reactivity is now a common denominator, so the argument that things work completely differently here lost much of its force.
Performance beyond change detection
Zoneless mode removes one bottleneck, and on large applications three more remain, worth checking in this order.
The first is initial bundle size. A framework with everything included weighs what it weighs, so lazily loading routes a user may never visit makes a bigger difference here than in lighter solutions. An admin panel fetched once somebody navigates to it is the simplest win available.
The second is lists. Rendering a thousand rows costs regardless of how change detection works, since the cost lies in creating elements in the document. The answer is virtualisation or pagination rather than another reactivity optimisation.
The third is the function identifying list items. Without it, changing one row recreates the whole list, which is visible to the eye on longer sets. That is a one line fix with a disproportionate effect.
Measure in that order, since intuition usually points the other way. Teams rewrite components onto signals expecting a speedup, while the time goes on fetching the bundle and rendering a list nobody virtualised.
Common mistakes
The first is omitting parentheses when reading a signal in a template. The function displays instead of the value, and the mistake does not always surface as an error.
The second is disabling Zone.js before moving state onto signals. The view stops refreshing in places nobody changed, and the symptom looks like broken data.
The third is holding derived values as separate signals updated by hand. Sooner or later they drift from their source.
The fourth is using effects where a derived value suffices. An effect exists for side effects rather than computation, and used for computation it can create loops.
The fifth is moving everything from streams to signals at once. Streams still make sense for events spread over time, polling or debouncing input for instance, and rewriting them by force makes the code worse.
The sixth is migrating without reaching for the automated tooling. Moving to standalone components and the new control flow syntax runs as a command, and doing it by hand wastes time.
The seventh is providing a service at a component where the state is meant to be shared. Every component instance then receives its own copy, and the symptom looks like lost data rather than a configuration error.
The eighth is relying on form validation rules alone. A request can be sent bypassing the interface, so the same rules must exist on the server side.
FAQ
Do I have to migrate to zoneless mode?
No. New projects start that way by default, while an existing application using Zone.js keeps working and nobody has announced removing that mode. Migration is a choice that pays on large applications, where checking the whole tree on every event costs.
Do signals replace RxJS?
Partly. For component state and derived values signals are simpler and sufficient. Streams remain useful for events spread over time: debouncing input, combining several sources, cancelling a previous request. Rewriting those cases as signals makes the code worse.
Where do I start migrating an old application?
With standalone components and the new control flow syntax, since automated tooling performs both. Then signals for local state, file by file. Leave disabling Zone.js until last, since it is only safe once the state affecting the view is already reactive.
Is Angular suitable for small projects?
It is suitable and rarely the best choice. This framework's value lies in imposed structure, which pays with a large team and long maintenance. On an application due in a quarter that same structure is a cost.
What about testing after this release?
In new projects the default tool is Vitest, and the experimental Jest and Web Test Runner builders were removed in this release. Projects on Karma keep working, and moving to Vitest has its own migration that runs during the update, so this is a change affecting the start rather than a requirement during maintenance.
Documentation sits on the framework site, and the move to zoneless mode in the migration guide.