03 Jun 2026
13 min

Angular 22: Key Features and Changes

Angular 22 is not about one big new feature. It is about several features becoming stable at the same time. The reactive APIs the team has built over the last two years (Signal Forms, resource() and httpResource) finally lose their experimental status and become part of the stable, supported API. At the same time, two defaults change in ways that affect every application: components now use OnPush change detection by default, and the router inherits route parameters from all parent routes by default. On top of that, Angular 22 adds something completely new. With WebMCP, your application and your forms can become tools that AI agents running in the browser can call directly.

OnPush Is the New Default

This change affects the most code, so it comes first. In Angular 22, a component that does not set its changeDetection property now uses ChangeDetectionStrategy.OnPush by default instead of the old “check always” behavior. This is the natural result of the zoneless, signal-first direction. Instead of re-checking the whole component tree on every event, Angular checks a component when something marks it dirty: a signal it reads changes, an input changes, an event fires, or you call markForCheck.

To make this safe, the team did two things. First, they added a new ChangeDetectionStrategy.Eager value that represents the old default (“check always”) behavior. Second, they shipped an automatic migration that adds ChangeDetectionStrategy.Eager to your existing components where needed. So an upgraded application keeps working exactly as before until you decide to modernize each component.

// New default in Angular 22 (no changeDetection needed):
@Component({
  selector: 'app-counter',
  template: `{{ count() }}`
})
export class Counter {
  count = signal(0); // OnPush + signals: updates just work
}

// The migration adds this to existing components to keep the old behavior:
@Component({
  selector: 'app-legacy',
  changeDetection: ChangeDetectionStrategy.Eager,
  template: ``
})
export class Legacy {}

Key points:

  • New components use OnPush by default. If you write them with signals, you usually do not need to think about change detection at all.
  • ChangeDetectionStrategy.Eager is the new name for the old default. After you migrate, searching your codebase for Eager is a good way to see how much of your app is not yet OnPush-ready. Each result is a candidate for cleanup.
  • The automatic migration does the upgrade for you. It adds Eager where the old behavior matters, and a follow-up fix makes sure it does not produce invalid code in edge cases.
  • This works together with zoneless, but it is a separate thing. Zoneless removes zone.js as the trigger. OnPush controls which views get checked. You want both for the best performance, but they are different concepts.

In short, new code gets the high-performance default for free, and existing apps keep working. The Eager markers act as a clear to-do list for step-by-step cleanup.

Signal Forms Go Stable

Signal Forms shipped as experimental in Angular 21. In Angular 22 they become stable. The experimental warnings are gone. If you started using them during the v21 window, the API you already know stays the same:

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',
  imports: [FormField],
  template: `
    <form (submit)="onSubmit($event)">
      <input type="email" [formField]="loginForm.email" />
      @if (loginForm.email().touched() && loginForm.email().invalid()) {
        @for (error of loginForm.email().errors(); track error) {
          <p class="error">{{ error.message }}</p>
        }
      }
      <input type="password" [formField]="loginForm.password" />
      <button type="submit" [disabled]="loginForm().invalid()">Log 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: 'Please enter a valid email' });
    required(f.password, { message: 'Password is required' });
  });

  onSubmit(event: Event) {
    event.preventDefault();
    if (this.loginForm().valid()) {
      console.log(this.loginModel());
    }
  }
}

The forms package also got several smaller improvements:

  • More public API: the date and limit validators (and related helpers) are now public and part of the stable contract.
  • Typed getError results: built-in errors are now properly typed, so you get real autocomplete instead of reading validation state as any.
  • A new field metadata guide documents patterns that used to be unofficial knowledge.
  • Performance: FormField.parseErrors no longer recomputes without reason, and redundant invalidations in the parser-errors signal were removed. That means less wasted work on every keystroke in large forms.
  • Clearer support and docs for plain-object models, which makes custom controls easier to write.

resource() and httpResource Go Stable

The other half of this story is reactive async data. The resource() and the httpResource() are getting stable. They let you keep async fetching inside the signal graph instead of moving in and out of RxJS for every request:

import { Component, signal } from '@angular/core';
import { httpResource } from '@angular/common/http';

@Component({
  selector: 'app-user',
  template: `
    @if (user.isLoading()) {
      <p>Loading…</p>
    } @else if (user.hasValue()) {
      <h1>{{ user.value().name }}</h1>
    }
  `
})
export class UserComponent {
  userId = signal(1);
  // Re-fetches automatically whenever userId changes.
  user = httpResource<User>(() => `/api/users/${this.userId()}`);
}

Two fixes matter for long-running apps in particular:

  • rxResource no longer leaks a subscription, and httpResource got the same fix. This was a slow memory growth in sessions that create many short-lived resources.
  • The resource URL sanitizer lookup is now case-insensitive. This closes a gap where schemes with different casing could get past the expected checks.

WebMCP: App and Forms as AI Tools (Experimental)

This is the truly new feature in Angular 22, and the most interesting one for teams thinking about AI in their apps. It is strictly experimental. The spec itself is early and changing, so expect this section to change too.

Today, most AI interactions with web apps are limited to what the model can read from the rendered DOM. That is fragile and shallow, because the business logic in your services and signals stays invisible to the agent. WebMCP (Web Model Context Protocol) is a new web standard that changes this. Your application registers structured tools on a browser-level object (navigator.modelContext and document.modelContext), and an AI agent built into the browser can find and call them directly. There is no DOM scripting and no separate server.

One clear distinction, because the names overlap. The Angular CLI MCP server helps coding agents understand your project at build time. WebMCP is a different thing. It runs in the browser, in the live page, and exposes the runtime capabilities of the running app to an in-browser agent. Same protocol family, opposite direction.

Angular’s job here is to connect WebMCP to dependency injection and the component lifecycle, so tools register and unregister automatically. There are three ways to use it.

Application-wide tools. Use provideExperimentalWebMcpTools in your app config. The execute callback runs in the injection context of the matching injector, so you can inject services directly:

import { Service, inject, provideExperimentalWebMcpTools } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { AppRoot } from './app-root';

@Service()
class Greeter {
  sayHello(): string { return 'Hello agent!'; }
}

bootstrapApplication(AppRoot, {
  providers: [
    provideExperimentalWebMcpTools([
      {
        name: 'greet',
        description: 'Greets the agent.',
        inputSchema: { type: 'object', properties: {} },
        execute: () => {
          const greeter = inject(Greeter);
          return { content: [{ type: 'text', text: greeter.sayHello() }] };
        }
      }
    ])
  ]
});

Route-scoped tools. You can register tools in a route’s providers so they only exist while that route is active. There is one thing to watch out for. Pair this with withExperimentalAutoCleanupInjectors() on the router. Otherwise the tools stay registered after the user leaves the route, and the agent keeps seeing capabilities that no longer fit the current screen.

import { provideRouter, withExperimentalAutoCleanupInjectors } from '@angular/router';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes, withExperimentalAutoCleanupInjectors())
  ]
};

Implicit tools from Signal Forms. This is the combination that makes the feature click, and the reason Signal Forms becoming stable and WebMCP arriving land in the same release. Add provideExperimentalWebMcpForms() to your providers, then pass an experimentalWebMcpTool option to a form(). Angular reads the form’s data model, generates a JSON schema automatically, and connects the tool to the form’s validators and submission handler. You write no schema by hand and no manual event handling.

import { Component, signal } from '@angular/core';
import { form, required } from '@angular/forms/signals';

@Component({ /* ... */ })
export class UserRegistration {
  private readonly model = signal({
    firstName: '',
    lastName: '',
    age: 0,
    hobbies: ['Web Development']
  });

  readonly userForm = form(
    this.model,
    (f) => {
      required(f.firstName, { message: 'First name is mandatory.' });
      required(f.lastName, { message: 'Last name is mandatory.' });
    },
    {
      experimentalWebMcpTool: {
        name: 'registerUser',
        description: 'Registers a new user.'
      },
      submission: {
        action: async (formValue) => {
          console.log('Submitting user:', formValue);
        }
      }
    }
  );
}

From this single declaration, Angular generates a tool whose schema includes firstName, lastName, age and hobbies (typed as a string array, taken from the non-empty initial value). It marks firstName and lastName as required based on the validators, and it connects the agent to the form’s validation and submission results. If the agent sends bad input, it sees the validation error and can correct itself and retry.

The constraints follow from how the inference works. Angular reads types from the initial value of the model, so you need concrete initial values (”, 0, false, not null or undefined) and non-empty arrays (it cannot infer the element type of []).

A few things to keep in mind:

  • It is experimental, and the spec changes often. The API can change even outside major versions, and on the browser side it currently needs Chrome behind a flag plus a polyfill for development.
  • Tool names must be unique. Registering the same name twice throws an error. Prefer application providers, route providers, or root services. Avoid component constructors unless the component appears on the page at most once.
  • Validate your inputs. Angular does not guarantee that an agent’s arguments match the declared schema. Validate inside execute before you act on them.
  • Async validators are not triggered by the implicit forms tool. Handle them in the submission action.

For enterprise Angular, this is the most forward-looking part of the release. It treats the form not as a piece of UI but as a typed capability that both people and agents can use.

Leaner Dependency Injection: @Service and injectAsync

Angular 22 makes services simpler to declare and adds a first-class way to load them lazily.

The @Service decorator (now stable). @Service() is a new decorator. By default it behaves like @Injectable({ providedIn: ‘root’ }), which means a tree-shakeable, application-wide singleton, but without the repeated configuration. There are two reasons behind it. First, the most common case is exactly a root-provided singleton, so that should be the default instead of something you opt into with an options object. Second, the name says what the class is. “Service” describes the thing, while “Injectable” describes a mechanism. @Injectable is not going away, but for a typical service @Service() is the more direct choice.

// Angular 22
import { Service } from '@angular/core';

@Service()
export class UserStore {
  // Root-provided, tree-shakeable singleton. No { providedIn: 'root' } needed.
}

injectAsync (lazy-loading services). The new injectAsync helper loads a service only when you actually need it. Your bundler splits the service into a separate chunk that is downloaded on first use. After that, Angular resolves it through the normal DI system, so it can still depend on other injectables and behaves like any other singleton.

import { Component, injectAsync } from '@angular/core';

@Component({
  selector: 'app-report',
  template: `<button (click)="export()">Export</button>`
})
export class Report {
  private exporter = injectAsync(() =>
    import('./report-exporter').then((m) => m.ReportExporter)
  );

  async export() {
    const exporter = await this.exporter();
    exporter.export();
  }
}

The first call triggers the dynamic import and resolves the service from DI. Later calls reuse the same promise, so the chunk is fetched only once. A few helpful details:

  • Default exports are unwrapped for you. You can pass the dynamic import directly, without .then(m => m.X).
  • Prefetching is opt-in through a trigger. Angular ships onIdle, which waits until the browser is idle (and accepts a timeout so the prefetch always happens within a known window): injectAsync(loader, { prefetch: () => onIdle({ timeout: 1_000 }) });
  • Custom triggers are simply functions that return a promise (a PrefetchTrigger), so you can tie prefetching to a hover, a scheduler tick, or any signal you like.

One requirement connects these two features. For lazy loading to work, the service must be auto-provided, which means decorated with @Injectable({ providedIn: ‘root’ }) or @Service(). Without auto-provisioning, Angular has no way to create it after it loads.

Router: paramsInheritanceStrategy Now Defaults to ‘always’ (Breaking)

This is a small default change that removes a familiar annoyance. Before, paramsInheritanceStrategy defaulted to ’emptyOnly’. A child route only inherited a parent’s params and data if the child had no component of its own. That led to the well-known route.parent?.parent?.snapshot.params chains just to reach a grandparent’s :id.

In Angular 22 the default is ‘always’. Route parameters and data are inherited from all parent routes by default, so a deeply nested route can read an ancestor’s parameter directly. This is a breaking change. If you relied on the ’emptyOnly’ behavior, set it back explicitly:

// Restore the previous behavior if you relied on it:
provideRouter(routes, withRouterConfig({ paramsInheritanceStrategy: 'emptyOnly' }));

For most apps this is a quality-of-life improvement that removes a layer of ?.parent plumbing. But because it changes what paramMap and data resolve to in nested routes, it is worth checking on purpose instead of assuming.

linkedSignal Gets a Custom set Option Expected in version 22.1

linkedSignal is the writable signal whose value is set and reset by a reactive computation. In Angular 22 it gains a custom set option. Until now you configured a linked signal with a source and computation (and optionally equal or debugName). The new option lets you intercept what happens when someone writes to the signal with .set() or .update(). The callback signature is:

set?: (value: NoInfer<D>, rawSet: (value: NoInfer<D>) => void) => void;

It receives the incoming value and a rawSet function that commits the value into the linked signal’s own state. The point of set is synchronization between signals. A linked signal already reads from a source, and this hook gives it a way to route writes back somewhere, in the same tick.

It is fair to ask what this adds over calling the source’s update directly. If the write is local, inside the same component, the answer is nothing, and reaching for linkedSignal would be over-engineering. You would just write this.task.update(t => ({ …t, status: ‘done’ })) at the call site and move on. The feature earns its place at a boundary, when you need to expose a slice of a larger state object to the outside as a standalone WritableSignal.

The clearest example is two-way binding to a generic child component. Imagine a reusable <status-picker> whose input is status = model<string>(). It knows nothing about your Task type, and it should not need to. You want [(status)] to work while task stays the single source of truth:

import { Component, signal, linkedSignal } from '@angular/core';

@Component({
  template: `<status-picker [(status)]="status" />`
})
class TaskComponent {
  task = signal<Task>({ id: 42, status: 'todo' });

  status = linkedSignal(() => this.task().status, {
    set: (s) => this.task.update(t => ({ ...t, status: s }))
  });
}

Here status is a writable view onto task().status. The child reads and writes a plain WritableSignal<string>, and every write is routed into task while the parent’s state shape stays hidden. Without this you would have two worse options. You could pass the whole task down, which leaks the Task shape into a generic component. Or you could fall back to a manual input and output pair and propagate changes by hand, which is exactly the imperative wiring that signals are meant to remove.

The second way to use the same mechanism is synchronizing with a state container, which is where most of the community discussion happens. Many people describe this feature in terms of delegatedSignal, the API proposed in the NgRx RFC (@ngrx/platform issue #5121, “Add delegatedSignal API”). That RFC asked for a primitive to synchronize state between SignalStore slice and a Signal Form, with a shape of { computation, update }. The workaround people use today is linkedSignal plus an effect that pushes the value to the external source. The RFC itself lists the limitations: the synchronization is indirect, needs extra wiring, and the effect does not propagate updates synchronously. The new set option closes that gap from the framework side:

readonly filter = linkedSignal({
  source: () => this.store.filter(),
  computation: (filter) => filter,
  set: (value) => this.store.updateFilter(value) // delegated synchronously, no effect
});

Whether NgRx still ships a dedicated delegatedSignal as a thin wrapper is a separate question, but the underlying capability now lives in core.

One thing to watch. Once you introduce the slice, there are two read paths to the same value (status() and task().status). That is fine as long as the write shape in set matches the read shape in computation. If you ever transform the value on write but not on read, or the other way around, the two paths drift apart. So keep them consistent.

Template Improvement: Comments Inside Element Tags

This is a small but welcome change that the community has asked for over many years. Angular 22 supports comments inside an HTML element, that is, between the attributes of an opening tag. You can now annotate or temporarily comment out a single attribute on a multi-line element without rewriting the markup. This was not possible in Angular templates before.

<input
  [value]="value"
  (input)="onInput($event)"
  <!-- (blur)="onBlur()"  temporarily disabled while we debug -->
  type="search"
/>

It will not change how anyone designs an app, but it removes a long-standing irritation in large templates with many attributes.

Security Hardening

If you scan the commit log, the most common word after “docs” is some form of “sanitize” or “SSRF.” Under the headline features, Angular 22 is a serious security release. This matters most for enterprise and SSR deployments.

Server-side request forgery (SSRF) protections. platform-server got fixes that secure location and document initialization against SSRF and path hijacking, reject suspicious URLs, restrict protocol-relative URLs, and prevent SSRF bypasses through backslash URLs in HttpClient. The URL resolution utility was simplified as part of this work, and a fix makes sure only a literal /index.html suffix is stripped from URLs.

Stricter sanitization. The framework now sanitizes dynamic href and xlink:href bindings on SVG <a> elements, sanitizes meta selectors, sanitizes placeholder values, and normalizes namespaced tag names in the DOM element schema registry and the runtime i18n attribute security-context lookup. Namespaced SVG <script> elements are stripped during template compilation, while namespaced SVG <style> elements are kept correctly. A <script> element is now also rejected as a dynamic component host.

TransferCache and credentials. There is one meaningful default change. The HTTP transfer cache now skips cookie-bearing requests by default and excludes withCredentials requests. This stops authenticated, user-specific responses from being written into the transferred state and shared by accident. This is the kind of quiet SSR data leak that is easy to miss in review.

Other hardening. zone.js now validates __Zone_symbol_prefix to prevent DOM-clobbering attacks. LOCALE_DATA is built with Object.create(null) as a defense against prototype pollution. There is also a maximum buffer size for fetch requests on SSR to limit memory use.

In short, most of these need no code changes from you. But the TransferCache change is worth a deliberate check if you relied on caching any responses that carry credentials.

Compiler and Type-Safety Improvements

Angular 22 continues to make the compiler stricter about template correctness.

  • Invalid @for loops are now type-checked. The compiler catches a group of mistakes at build time instead of at runtime.
  • NgModules can be compiled under TypeScript’s isolatedDeclarations, which helps with faster, more parallel builds.
  • A documented typeCheckHostBindings option surfaces a class of host-binding errors that used to slip through.
  • Event attribute bindings in host bindings are now disallowed in all cases, which closes an inconsistency.
  • Deprecated shadow CSS encapsulation polyfills and legacy shadow DOM selector support were removed, and :host and :host-context handling was simplified. If you relied on very old emulated-encapsulation behavior, test your styles.

Language Service and DevTools

  • The language service now type-checks templates that would need inline type-check blocks, and it compiles non-exported classes when they are standalone. In practice, more of your templates get accurate diagnostics and autocomplete in the editor, including components you did not export.
  • Angular DevTools moves to 1.15.0, with a better signal graph (clearer cluster-to-cluster relationships) and early support for inspecting component trees that include non-Angular frameworks.
  • The VS Code extension is now safer in untrusted workspaces. It prompts before loading a workspace TSDK, restricts JSDoc markdown trust, and disables the language server in untrusted workspaces. This is a sensible response to the fact that opening a repository should not run its tooling on its own.

Conclusion

Angular 22 is the release where many long-running efforts come together at once. The reactive, signal-first model becomes the default direction across change detection, forms, and async data. At the same time, the framework takes its first real step toward AI agents inside the web app itself.

The changes with the biggest impact:

  1. OnPush as the default. High-performance change detection for free on new components, with an automatic migration that carries existing apps across safely.
  2. Signal Forms stable. Production forms on the signal-based API, with no experimental label.
  3. resource() and httpResource stable. Reactive async data, inside the signal graph, fully supported.
  4. paramsInheritanceStrategy defaults to ‘always’. The end of route.parent?.parent?.snapshot.params, but check your nested routes.
  5. @Service and injectAsync. Simpler service declaration and first-class lazy-loaded services.
  6. Security hardening. SSRF protections, stricter sanitization, and safer TransferCache defaults, mostly for free.
  7. WebMCP (experimental). The most forward-looking feature. Your forms and services can become typed tools that an in-browser agent can call directly.

For production work today, rely on the stable features, the new defaults, and the security fixes. For where Angular is heading, watch WebMCP and the foreign components groundwork closely. That is the shape of the next few releases.

Share this post

Sign up for our newsletter

Stay up-to-date with the trends and be a part of a thriving community.