Angular 22 released June 3, 2026. It's not one big feature — it's the moment several parallel efforts across the past two years all landed stable at the same time. Here's what actually changed and what it means for your code.
1. OnPush Is Now the Default
This is the change that touches the most existing code. Before Angular 22, a component with no changeDetection property used ChangeDetectionStrategy.Default — checking the whole tree on every event. Now it uses OnPush.
// Angular 22 — OnPush is the default, no config needed @Component({ selector: 'app-counter', standalone: true, template: `<h2>{{ count() }}</h2>` }) export class CounterComponent { count = signal(0) // signals + OnPush: updates just work }
The Angular team handled backward compatibility cleanly. A new ChangeDetectionStrategy.Eager value preserves the old "check always" behavior, and the CLI migration adds it automatically to your existing components:
// The migration adds this to components that relied on the old default @Component({ selector: 'app-legacy', changeDetection: ChangeDetectionStrategy.Eager, // ← migration-added template: `...` }) export class LegacyComponent {}
After migration, Eager in your codebase becomes a to-do list — each occurrence marks a component that hasn't been converted to signals + OnPush yet. Work through them at your own pace.
What this means practically: new components you write today are high-performance by default. If you build with signals (and you should), you won't need to think about change detection at all — signal reads automatically mark the view dirty when their value changes.
2. Signal Forms Go Stable
Signal Forms shipped as experimental in Angular 21. In Angular 22 they're stable — no more experimental warnings, and the API is part of the supported contract.
import { Component, signal } from '@angular/core' import { form, FormField, required, email } from '@angular/forms/signals' interface LoginData { email: string password: string } @Component({ selector: 'app-login', standalone: true, imports: [FormField], template: ` <form (submit)="onSubmit($event)"> <div class="mb-3"> <label class="form-label fw-semibold">Email</label> <input type="email" class="form-control" [formField]="loginForm.email" /> @if (loginForm.email().touched() && loginForm.email().invalid()) { @for (error of loginForm.email().errors(); track error) { <div class="invalid-feedback d-block">{{ error.message }}</div> } } </div> <div class="mb-4"> <label class="form-label fw-semibold">Password</label> <input type="password" class="form-control" [formField]="loginForm.password" /> </div> <button type="submit" class="btn w-100 text-white fw-semibold" style="background:#fd4766;" [disabled]="loginForm().invalid()"> Sign In </button> </form> ` }) export class LoginComponent { loginModel = signal<LoginData>({ email: '', password: '' }) loginForm = form(this.loginModel, (f) => { required(f.email, { message: 'Email is required' }) email(f.email, { message: 'Enter a valid email' }) required(f.password, { message: 'Password is required' }) }) onSubmit(event: Event) { event.preventDefault() if (this.loginForm().valid()) { console.log(this.loginModel()) // { email: '...', password: '...' } } } }
What changed in Angular 22 vs 21:
- Built-in error results are now properly typed (autocomplete instead of
any) FormField.parseErrorsno longer recomputes without reason (better perf on large forms)dateandlimitvalidators are now public stable API- Plain-object models now have first-class documentation and support
3. resource() and httpResource() Go Stable
The two reactive async primitives are now stable. They keep HTTP requests inside the signal graph — no moving back and forth between RxJS for every data fetch:
import { Component, signal } from '@angular/core' import { httpResource } from '@angular/common/http' interface Template { id: number name: string framework: string price: number } @Component({ selector: 'app-template-detail', standalone: true, template: ` @if (template.isLoading()) { <div class="d-flex justify-content-center py-5"> <div class="spinner-border" style="color:#fd4766;"></div> </div> } @else if (template.hasValue()) { <div class="card border-0 shadow-sm p-4"> <span class="badge mb-2 text-white" style="background:#fd4766;"> {{ template.value().framework }} </span> <h1 class="fw-bold">{{ template.value().name }}</h1> <p class="text-muted">${{ template.value().price }}</p> </div> } ` }) export class TemplateDetailComponent { templateId = signal(1) // Re-fetches automatically when templateId changes template = httpResource<Template>( () => `/api/templates/${this.templateId()}` ) }
Two important bug fixes landed with the stable release:
rxResourceandhttpResourceno longer leak subscriptions — this was causing slow memory growth in long-running sessions- The URL sanitizer lookup is now case-insensitive, closing a scheme-bypass gap
4. @Service Decorator — Simpler DI
@Service() replaces @Injectable({ providedIn: 'root' }) for the common case — a root-scoped, tree-shakeable singleton:
// Before Angular 22 import { Injectable } from '@angular/core' @Injectable({ providedIn: 'root' }) export class TemplateService { getAll() { return fetch('/api/templates').then(r => r.json()) } } // Angular 22 — same result, less ceremony import { Service } from '@angular/core' @Service() export class TemplateService { getAll() { return fetch('/api/templates').then(r => r.json()) } }
@Injectable isn't going away — use it when you need non-root scoping or explicit configuration. @Service() is just the shorthand for the 90% case.
5. injectAsync() — Lazy-Loaded Services
injectAsync loads a service only when it's first called — the bundler splits it into a separate chunk that downloads on demand:
import { Component, injectAsync } from '@angular/core' @Component({ selector: 'app-report', standalone: true, template: ` <button class="btn text-white fw-semibold" style="background:#fd4766;" (click)="export()"> Export PDF </button> ` }) export class ReportComponent { // PDFExporter is NOT downloaded until export() is first called private pdfExporter = injectAsync(() => import('./pdf-exporter').then(m => m.PDFExporter) ) async export() { const exporter = await this.pdfExporter() exporter.export() } }
Prefetch on idle — start downloading before the user clicks, but don't block the initial load:
import { injectAsync, onIdle } from '@angular/core' private pdfExporter = injectAsync( () => import('./pdf-exporter').then(m => m.PDFExporter), { prefetch: () => onIdle({ timeout: 2000 }) } // prefetch within 2 seconds of idle )
One requirement: the service must be auto-provided (@Service() or @Injectable({ providedIn: 'root' })). Without that, Angular can't instantiate it after the dynamic import resolves.
6. Router: paramsInheritanceStrategy Defaults to 'always'
Before Angular 22, reading a grandparent route's :id in a deeply nested child required .parent?.parent?.snapshot.params chains. Now route parameters inherit from all parent routes by default:
// Before Angular 22 — accessing grandparent param was painful export class OrderItemComponent implements OnInit { ngOnInit() { // Had to climb the tree manually const orderId = this.route.parent?.parent?.snapshot.params['orderId'] } } // Angular 22 — grandparent :orderId available directly export class OrderItemComponent implements OnInit { ngOnInit() { const orderId = this.route.snapshot.params['orderId'] // just works } }
This is a breaking change. If your app relied on 'emptyOnly' behavior (where children with their own components didn't inherit parent params), restore it explicitly:
// Restore previous behavior if needed provideRouter(routes, withRouterConfig({ paramsInheritanceStrategy: 'emptyOnly' }))
For most apps this is a quality-of-life improvement. Worth a deliberate check on nested routes before upgrading.
7. WebMCP — Experimental AI Integration
The most forward-looking feature in Angular 22. WebMCP (Web Model Context Protocol) lets your application register typed tools that an in-browser AI agent can call directly — no DOM scraping, no separate server:
Application-wide tools:
import { Service, inject, provideExperimentalWebMcpTools } from '@angular/core' import { bootstrapApplication } from '@angular/platform-browser' @Service() class TemplateService { search(query: string) { return fetch(`/api/templates?q=${query}`).then(r => r.json()) } } bootstrapApplication(AppComponent, { providers: [ provideExperimentalWebMcpTools([ { name: 'searchTemplates', description: 'Search Bootstrap and Angular admin dashboard templates.', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Search query' } }, required: ['query'] }, execute: (input) => { const service = inject(TemplateService) return service.search(input.query).then(results => ({ content: [{ type: 'text', text: JSON.stringify(results) }] })) } } ]) ] })
Implicit tools from Signal Forms — the feature that makes WebMCP actually click. Add provideExperimentalWebMcpForms() and pass an experimentalWebMcpTool option to a form(). Angular reads the form's data model, generates a JSON schema automatically, connects validation, and hooks up the submission handler. No schema written by hand:
@Component({ /* ... */ }) export class UserRegistrationComponent { private model = signal({ firstName: '', lastName: '', email: '', }) userForm = form( this.model, (f) => { required(f.firstName, { message: 'First name is required.' }) required(f.lastName, { message: 'Last name is required.' }) email(f.email, { message: 'Enter a valid email.' }) }, { experimentalWebMcpTool: { name: 'registerUser', description: 'Registers a new user account.' }, submission: { action: async (formValue) => { await fetch('/api/users', { method: 'POST', body: JSON.stringify(formValue) }) } } } ) }
The AI agent sees firstName, lastName, email as typed fields with validation rules — can fill the form, see validation errors if it sends bad data, correct itself, and submit. The form is no longer just UI; it's a capability that both humans and agents can use.
Important constraints:
- Strictly experimental — the WebMCP spec is still evolving, API may change outside major versions
- Tool names must be unique across the app — registering the same name twice throws
- Angular doesn't validate agent arguments against your schema — validate inside
executeyourself - Async validators are not triggered by the implicit forms tool — handle them in the submission action
- Currently needs Chrome behind a flag + a polyfill for development
8. linkedSignal Gets a Custom set Option
linkedSignal now accepts a set option that intercepts writes, letting you expose a slice of larger state as a standalone WritableSignal:
import { Component, signal, linkedSignal } from '@angular/core' interface Task { id: number title: string status: 'todo' | 'in-progress' | 'done' } @Component({ selector: 'app-task', standalone: true, template: ` <!-- status-picker only knows WritableSignal<string> — doesn't need to know Task --> <app-status-picker [(status)]="status" /> ` }) export class TaskComponent { task = signal<Task>({ id: 1, title: 'Build feature', status: 'todo' }) // Exposes task().status as a writable view // Writes route back into task, keeping it as the single source of truth status = linkedSignal(() => this.task().status, { set: (newStatus) => this.task.update(t => ({ ...t, status: newStatus })) }) }
Summary Table
| Feature | Status in v22 | What changed |
|---|---|---|
| OnPush change detection | Default | Auto migration adds Eager to existing components |
| Signal Forms | Stable | No more experimental label, typed errors, better perf |
resource() / httpResource() | Stable | Memory leak fix, case-insensitive URL sanitizer |
@Service() decorator | Stable | Shorthand for @Injectable({ providedIn: 'root' }) |
injectAsync() | Stable | First-class lazy-loaded services with prefetch triggers |
paramsInheritanceStrategy | Default changed | Now 'always' — breaking change, check nested routes |
linkedSignal set option | Expected v22.1 | Route writes back to source signal synchronously |
| WebMCP | Experimental | Forms and services as AI agent tools |
| Comments inside element tags | Stable | <!-- comment --> now allowed between attributes |
| Security hardening | Stable | SSRF protections, stricter sanitization, TransferCache fix |
For new projects, start with Angular 22's defaults — you get OnPush, signals, and @Service() for free. For existing projects, run the migration schematic and then work through the Eager markers at whatever pace suits your team.
Frequently Asked Questions
Related
Need a Full Angular + Bootstrap Admin Dashboard?
Marvel Angular Dashboard — Angular 21 + Bootstrap 5 with 50+ components and dark mode.
Browse Templates →Use code FIRST30 for 30% off.