SCROLL TO EXPLORE •SCROLL TO EXPLORE •
Signal Boosting <mark>Angular 21</mark>

Signal Boosting Angular 21

Signal-driven, reactive, and zoneless

Nicky Haze - 6 min read

Angular 21: The next step in Angular's reactive revolution

The Angular team keeps moving fast and with v21 (released November 2025) we're entering the next stage of Angular becoming even more reactive and zoneless. Angular 20 stabilised their signals and zoneless APIs and Angular 21 now comes with some features build on top of that foundation, introducing: Signal Forms, expanding zoneless support, and improving SSR and rendering performance.

This is a release with a clear view of where Angular is going, and it actually continues to move more towards a more "signal-driven" framework.

Signal Forms

In my experience Forms have always been quite complex in the Angular framework. Back in the days Reactive Forms were great and gave us structure, but this structure came with quite some boilerplate and dependencies on RxJS. With Signal Forms (Developer Preview) Angular 21 introduces managing form state using signals.

This new approach means:

  • No more FormGroup/FormControl hierarchies
  • No subscription management
  • Direct access to field state (touched, dirty, invalid) as signals
  • Seamless two-way binding via the new Field directive

Example: Building a profile form with signals

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

@Component({
    selector: 'app-profile-form',
    imports: [Field],
    template: `
    <form (submit)="onSubmit($event)">
      <label>First name</label>
      <input [field]="profileForm.name.first" />
      @if (profileForm.name.first().invalid()) {
        <span>Required</span>
      }

      <label>Last name</label>
      <input [field]="profileForm.name.last" />
      
      <label>Username</label>
      <input [field]="profileForm.username" />

      <label>Email</label>
      <input type="email" [field]="profileForm.email" />

      <label>Age</label>
      <input type="number" [field]="profileForm.age" />

      <button type="submit">Save</button>
    </form>
  `,
})
export class ProfileFormComponent {
    readonly profile = signal({
        name: { first: '', last: '' },
        username: '',
        email: '',
        age: null as number | null,
    });

    readonly profileForm = form(this.profile, (p) => {
        // Name schema
        schema(p.name, (n) => {
            required(n.first, { message: 'First name is required' });
            required(n.last, { message: 'Last name is required' });
        });

        required(p.username, { message: 'Username is required' });
        required(p.email, { message: 'Email is required' });
        required(p.age, { message: 'Age is required' });
    });

    onSubmit(event: Event): void {
        console.log('Submitted', this.profile());
    }
}

As you can see the magic here lies in form() and schema(). Instead of manually connecting FormControls, you simply describe what the form looks like, and Angular does the rest. Love it!

Validation

Validation also becomes declarative. Here's how to create a custom validator that checks for the user being at least 18 years old:

synchronous validator

// age.validator.ts
import { Field, ValidationError, validate } from '@angular/forms/signals';

export function adultValidator(field: Field<number | null>) {
    validate(field, (ctx) => {
        const age = ctx.value();

        if (age === null || Number.isNaN(age)) {
            return null;
        }

        return age >= 18
            ? []
            : [
                {
                    kind: 'min_age',
                    message: 'You must be at least 18 years old.',
                } satisfies ValidationError,
            ];
    });
}

Using it inside the form becomes very easy and clean:

readonly profileForm = form(this.profile, (p) => {
  // Name schema
  schema(p.name, (n) => {
    required(n.first, { message: 'First name is required' });
    required(n.last, { message: 'Last name is required' });
  });

  required(p.username, { message: 'Username is required' });
  required(p.email, { message: 'Email is required' });
  required(p.age, { message: 'Age is required' });
  
  // Apply custom validator
  adultValidator(p.age);
});

The template can read errors() directly from the field signal:

@if (profileForm.age().invalid()) {
  <span>{{ profileForm.age().errors()[0]?.message }}</span>
}

asynchronous validator

For API-driven validation (e.g. to check if a username is unique) Angular 21 provides validateHttp(). It lets you define a request and convert the result into validation errors without dealing with RxJS subscriptions.

// username-unique.validator.ts
import {
    Field,
    ValidationError,
    validateHttp,
    customError,
} from '@angular/forms/signals';

interface UsernameCheckResult {
    unique: boolean;
}

export function userNameUniqueValidator(field: Field<string>) {
    validateHttp<UsernameCheckResult>(field, {
        request: ({ value }) => {
            const username = value();
            if (!username) return null;

            return {
                url: `https://api.example.com/check/${encodeURIComponent(username)}`,
                method: 'GET',
            };
        },
        errors: (result): ValidationError[] => {
            if (!result) return [];

            return result.unique
                ? []
                : [
                    customError({
                        kind: 'notUnique',
                        message: 'Username already taken',
                    }),
                ];
        },
    });
}

Using it inside the form again becomes very easy and clean:

readonly profileForm = form(this.profile, (p) => {
  // Name schema
  schema(p.name, (n) => {
    required(n.first, { message: 'First name is required' });
    required(n.last, { message: 'Last name is required' });
  });

  required(p.email, { message: 'Email is required' });

  required(p.username, { message: 'Username is required' });
  // Apply custom async validator
  usernameUniqueValidator(p.username);

  required(p.age, { message: 'Age is required' });
  // Apply custom validator
  adultValidator(p.age);
});

This new API makes it easy to create reusable validators that work directly with signals.

Even though Signal Forms are still experimental in v21, they're already awesome to work with. I expect it to become the default way to handle forms in Angular in the near future.

Zoneless by default

Starting with Angular 21, zoneless change detection is now enabled by default. No more Zone.js dependency. The Zoneless API has been stable since Angular 20.2, but version 21 takes it further: there's no need to import provideZonelessChangeDetection() in your app config, as all new Angular applications are now zoneless out of the box.

Why zoneless?

Without zone.js, Angular no longer needs to patch async APIs to track changes. Combining this with signals, the benefits are:

  • Less unnecessary re-renders
  • Better performance
  • Easier to debug

Most first-party libraries now support zoneless mode and third-party library support is growing too.

SSR and incremental hydration

Angular 21 improves Server-Side Rendering (SSR) and hydration with:

  • Route-level render modes
  • Incremental hydration for large apps
  • Improved DX for edge deployments

Example configuration client and server routes with different rendering strategies:

// app.routes.server.ts
import { RenderMode, ServerRoute } from '@angular/ssr';

export const serverRoutes: ServerRoute[] = [
    {path: '', renderMode: RenderMode.Client},         // Rendered on the client like a typical SPA
    {path: 'login', renderMode: RenderMode.Server},     // Always rendered on the server 
    {path: 'dashboard', renderMode: RenderMode.Client}, // Rendered on the client like a typical SPA 
    {path: '**', renderMode: RenderMode.Prerender},     // This page is static, so it's pre-rendered during build time
];
// app.config.server.ts
import { ApplicationConfig } from '@angular/core';
import { provideServerRendering, withRoutes } from '@angular/ssr';
import { serverRoutes } from './app.routes.server';

export const serverConfig: ApplicationConfig = {
    providers: [
        provideServerRendering(withRoutes(serverRoutes)),
    ],
};

Example client configuration:

// app.routes.ts (client)
import { Routes } from '@angular/router';

export const routes: Routes = [
    {
        path: '',
        loadComponent: () =>
            import('./home/home').then(m => m.HomeComponent),
        title: 'Home'
    },
    {
        path: 'login',
        loadComponent: () =>
            import('./auth/login').then(m => m.LoginComponent),
        title: 'Login'
    },
    {
        path: 'dashboard',
        loadComponent: () =>
            import('./dashboard/dashboard').then(m => m.DashboardComponent),
        title: 'Dashboard'
    },
    {
        path: '**',
        loadComponent: () =>
            import('./shared/not-found').then(m => m.NotFoundComponent),
        title: 'Not Found'
    }
];
// app.config.ts (client)
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';

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

Angular now allows you to choose the optimal rendering strategy per route, which is great for performance and SEO.

HttpClient is in the framework by default

Until Angular 20, you always had to add:

import { HttpClientModule } from '@angular/common/http';

bootstrapApplication(AppComponent, {
    providers: [provideHttpClient()]
});

But in Angular 21, HttpClient is included by default. No more extra imports.

Angular ARIA

Angular 21 introduces Angular ARIA (developer preview). Angular ARIA is a library of unstyled directives that implement accessibility patterns especially for when you don't want to rely on Angular CDK (since it's large).

Example usage

// before
<div role="menu" aria-label="Main menu">
    <div role="menuitem" tabindex="0" aria-selected="false">Home</div>
    <div role="menuitem" tabindex="-1" aria-selected="false">About us</div>
    <div role="menuitem" tabindex="-1" aria-selected="false">Contact us</div>
</div>

// After:
<div ngMenu>
    <ng-template ngMenuContent>
        <div ngMenuItem>Home</div>
        <div ngMenuItem>About us</div>
        <div ngMenuItem>Contact us</div>
    </ng-template>
</div>
<div ngListbox>
  @for (item of items(); track item.id) {
    <div [value]="item.value" ngOption>{{ item.name }}</div>
  }
</div>

Available directives

These are the available directives:

  • Accordion
  • Combobox
  • Grid
  • Listbox
  • Menu
  • Tabs
  • Toolbar
  • Tree

Checkout these examples for more information.

Angular CLI and tooling

  • Vitest: New default testing framework. Angular 21 introduces Vitest as the new standard testing framework, replacing Jasmine and Karma for new projects.
  • Simplified config: angular.json continues to shrink, removing legacy build options.
  • Tailwind CSS config: CLI now supports Tailwind CSS config generation, which makes it easy to use Tailwind CSS in new Angular projects.

Please Note

This isn't everything Angular 21 brings, check out the official Angular 21 release notes and Angular GitHub changelog for more.

Final thoughts

Even though this release isn't revolutionary I'm excited to see what Angular 21 offers. The introduction of Signal Forms, focus on signals and zoneless architecture makes Angular a modern, efficient framework for building web applications. If you're using Angular, upgrading to v21 is definitely worth considering.

Photo of Nicky Haze

Nicky Haze

Lead Frontend Engineer