What’s new in Angular 21?

Angular 21, released at the end of 2025, brings several new features: Signal Forms as a reactive form API, Zone-less as the new standard model for change detection, vitest as a modern test environment, a set of ARIA directives for accessible components, and an enhanced MCP server for AI-supported development.

This article provides an overview of these features using examples.

📂 Source Code

Signal Forms

The currently experimental Signal Forms allow for efficient form design based on signals. This new API is similarly lightweight to template-driven forms, while offering the power of reactive forms. Furthermore, it enables the very simple creation of compound forms ("Form Groups") and repeating groups ("Form Arrays"):

Example application

The implementation is based on a Signal with the desired form structure. For this signal, the form function generates a so-called FieldTree, which enables data binding to the form:

@Component([…])
export class FlightSearchComponent {
  filter = signal({
    from: 'Graz',
    to: 'Hamburg',
    details: {
      maxLayovers: 0,
      maxPrice: 200
    },
    layovers: [
      { airport: '', minDuration: 0}
    ] as Layover[]
  });

  filterForm = form(this.filter, (path) => {
    required(path.from);
    minLength(path.from, 3);

    required(path.to);
    minLength(path.to, 3);

    const allowed = ['Graz', 'Hamburg', 'Paris'];
    validateAirport(path.from, allowed);
  });

  addLayover(): void {
    this.filter.update(filter => ({
      ...filter,
      layovers: [
        ...filter.layovers,
        {
          airport: '',
          minDuration: 0
        }
      ]
    }));
  }

  search(): void {
    const { from, to } = this.filterForm().value();

    // Alternative
    // const from = this.filterForm.from().value();
    // const to = this.filterForm.to().value();
    […]
  }

}

Technically, a FieldTree is a signal that represents the state of a form section -- an object, an array or a single property that is bound to a field. It manages information like value, dirty, and errors. For each nested property, such as maxLayovers and maxPrice, it contains another FieldTree.

The directive field binds a FieldTree to an input field:

<form>
    <input [field]="filterForm.from" />
    <div>{{ filterForm.from().errors() | json }}</div>

    <input [field]="filterForm.to" />
    <div>{{ filterForm.to().errors() | json }}</div>

    <!-- "Field Group" -->
    <input
      [field]="filterForm.details.maxLayovers"
      type="number"
    />
    <input
      [field]="filterForm.details.maxPrice"
      type="number"
    />

    <!-- "Field Array" -->
    @for (layover of filterForm.layovers; track $index) {
        <input [field]="layover.airport" />          
        <input
            [field]="layover.minDuration"
            type="number"
        />
    }
    […]
</form>

For improved readability, the form markup shown has been reduced to the essential parts. The optional second parameter of form accepts a schema that primarily contains validation rules. Signal Forms comes with the usual rules such as required and minLength. Custom validation rules can be implemented using the validate function:

function validateAirport(path: SchemaPath<string>, allowed: string[]) {
  validate(path, (ctx) => {
    if (allowed.includes(ctx.value())) {
      return null;
    }
    return {
      kind: 'airport_not_supported',
      allowed,
      actual: ctx.value(),
    };
  });
}

In addition to validation rules, a schema can define debouncing or specify when certain fields should be deactivated.

More on Signal Forms

Please find more information on Signal Forms in my article "All About Angular's new Signal Forms":

Zone-less by Default

From the very beginning, Zone.js has been the foundation for change detection in Angular. This library, included with the framework, uses monkey patching to modify existing browser objects such as window, document, and Promise. This allows it to determine when an event handler has been executed. After an event handler ran, Zone.js notifies the framework, which then checks the components for changes.

Zone.js also has some disadvantages. These include the overhead of around 30 KB (compressed), which is usually negligible for business applications. More serious, however, is the intervention in existing browser objects, as this complicates troubleshooting and leads to less traceable stack traces. Furthermore, there is a risk that Zone.js will trigger change detection too frequently - it has no knowledge of whether the current event handler has actually modified bound data.

The new zone-less change detection no longer requires Zone.js. To inform Angular about changes to bound properties, signals or observables must now be bound. The latter is done as usual via the async pipe. The objects managed by these reactive data structures should also be immutable so that Angular, in OnPush mode, can recognize which subcomponents need to be checked.

Since Angular 21, Zone-less change detection is on by default. If you want to stick with Zone.js, add provideZoneChangeDetection to your providers when bootstrapping:

// switching back to Zone-full CD
bootstrapApplication(AppComponent, {
  providers: [
    provideZoneChangeDetection()
  ],
});

In this case, you also have to add zone.js to the polyfills in your angular.json.

Generally, Zone-less should work wherever OnPush works. Nevertheless, it's advisable to thoroughly test the application in the new mode. Challenges might arise with third-party components that rely on the original behavior. In such cases, it's necessary to switch to a newer version (or wait for one).

Vitest: Migration and Fake Timer

The Angular team has been searching for a successor to the deprecated unit testing tool Karma for some time. They have now chosen the popular vitest. New projects are automatically set up with the new, dedicated unit test builder (@angular/build:unit-test).

Karma support remains available for existing projects. An experimental schematic is available for automated migration:

ng g @schematics/angular:refactor-jasmine-vitest

The new vitest-based unit tests always run zone-less. Hence, ideally, the application should also be migrated to Zone-less when using vitest. Since most calls remain unchanged, existing knowledge about Angular testing can be transferred almost directly.

However, a few things need to be changed. For example, the setup of spies changes slightly. spyOn is now, for instance, a method in the vi object:

vi.spyOn(flightService, 'find');

Depending on the configuration, vi is either globally available or must be imported separately:

import { vi } from 'vitest';

Spies in vitest delegate to the observed implementation by default - the call .and.callThrough() known from Jasmine is omitted. The object returned by spyOn also has a slightly different form, although the basic functionality remains unchanged. A mock implementation can thus be set up, for example, as follows:

vi.spyOn(flightService, 'find')
    .mockImplementation((_from, _to) => of([]));

In addition to these minor adjustments, vitest also brings a whole amount of new features familiar from Jest. These include snapshot testing, extensive import mocking capabilities, and parallel test execution.

As Vitest always runs Zone-less in Angular, the notorious Zone.js testing utilities - including fakeAsync and tick cannot be used anymore. Affected tests must now be structured differently. For example, the test might replace asynchronous routines with synchronous mocks. This approach is also known from testing the HttpClient.

Those who still want to "fast-forward" time can use the fake timers offered by vitest:

import { TestBed } from '@angular/core/testing';
import { debounceTime, Subject } from 'rxjs';
import { vi } from 'vitest';
import { toSignal } from '@angular/core/rxjs-interop';

describe('simulated input', () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });

  it('is updated after debouncing', async () => {
    await TestBed.runInInjectionContext(async () => {
      const input = createInput();
      input.set('Hallo');
      await vi.runAllTimersAsync();
      expect(input.value()).toBe('Hallo');
    });
  });
});

// Simulates debounced input
function createInput() {
  const input = new Subject<string>();
  const inputSignal = toSignal(input.pipe(debounceTime(300)), {
    initialValue: '',
  });
  return {
    value: inputSignal,
    set(value: string) {
      input.next(value);
    },
  };
}

The function beforeEach function activates the fake timers for each test case in the suite. After the content is set, the timers for debouncing must be resolved before the test can read the new value. This is done using runAllTimersAsync.

Unlike its synchronous alternative, runAllTimers, runAllTimersAsync not only resolves all timers but also waits for the microtasks they trigger. Examples of these are microtasks that arise from Promises in the timer callback.

Browser Mode in Vitest

Unlike Karma, vitest doesn't run in the browser by default, but directly in Node.js. The CLI integration simulates the DOM using the packages happy-dom or jsdom. One of these must be installed. If both are present, happy-dom takes precedence. While such tests are somewhat faster, they also run in an artificial environment.

Therefore, vitest also offers a browser mode. In this mode, a provider is used for communication with the browser. The vitest team recommends the Playwright-based provider, especially since it accelerates execution through parallelization:

npm install @vitest/browser-playwright -D

To activate browser mode, the browsers to be used must be entered in the angular.json file:

"test": {
  "builder": "@angular/build:unit-test",
  "options": {
    "browsers": ["Chromium"]
  },
  "configurations": {
    "ci": {
      "browsers": ["ChromiumHeadless"]
    }
  }
}

The shown example is using Chromium. Additionally, for execution on the build server, it includes a ci configuration that uses the headless version of Chrome. To interact with the page, vitest provides a page object for browser mode:

import { page } from 'vitest/browser';
[…]

it('should have a disabled search button w/o params', async () => {

  await page.getByLabelText('from').fill('');
  await page.getByLabelText('to').fill('');

  const button = page.getByRole('button', { name: 'search' }).element() 
          as HTMLButtonElement;

  const disabled = button.disabled;

  expect(disabled).toBeTruthy();
});

Aria properties are used to retrieve elements from the page. For example, the getByLabelText('from') method searches for an element with the attribute aria-label="from", and getByRole('button', { name: 'search' }) references a button with aria-label="search". The name parameter here refers to the aria-label attribute, not the name attribute.

As the discussed example shows, the objects retrieved via page allow the simulation of user actions. They have methods such as fill for this purpose. Other methods not shown here include clear (clearing the field content), click, dblClick, hover, and unhover. Additional user actions are enabled by the userEvent object in the vitest/browser package.

When ng test is run in browser mode, the CLI opens the configured browser(s) and executes the tests within it:

Vitest Browser Mode

The middle section displays the component being tested during test execution. Afterwards, the DOM in this area is reset so that subsequent tests can run without interference. However, when debugging, for example using a breakpoint in the browser's developer tools, the current state is visible there. This can be useful for troubleshooting.

More on Testing

Please find more information on Testing in our Professional Angular Testing workshop:

Angular Aria

The new @angular/aria package introduces accessible Directives that implement WAI-ARIA patterns. Out of the box, they support keyboard navigation, ARIA attributes, focus management, and screen reader integration. These directives are not intended for direct use in applications, but rather as a foundation for custom component libraries with their own unique design.

The following image shows an overview of the directives offered in version 21 on the left, and a tree view next to it. Since these directives are intentionally unstyled (headless), the styling from angular.dev has been applied to them:

Overview of components

The next listing, that has been slightly simplified and taken from the documentation, implements a grid:

<table ngGrid class="basic-data-table">
  <thead>
    <tr ngGridRow>
      <th ngGridCell>
        <input
          ngGridCellWidget
          aria-label="Select all rows"
          type="checkbox"
          [checked]="allSelected()"
          (change)="updateSelection($event)"
          #headerCheckbox
        />
      </th>
      <th ngGridCell>ID</th>
      <th ngGridCell>Task</th>
      <th ngGridCell>Priority</th>
    </tr>
  </thead>
  <tbody>
    @for (task of data(); track task.taskId) {
    <tr ngGridRow>
      <td ngGridCell>
        <input
          ngGridCellWidget
          aria-label="Select row {{$index + 1}}"
          type="checkbox"
          [(ngModel)]="task.selected"
        />
      </td>
      <td ngGridCell>{{task.taskId}}</td>
      <td ngGridCell>{{task.summary}}</td>
      <td ngGridCell>{{task.priority}}</td>
    </tr>
    }
  </tbody>
</table>

Here is what this grid looks like:

Angular Aria Grid

The following directives work together to implement this grid: Grid, GridRow, GridCell, GridCellWidget. This rather long code example for a simple grid underscores that the directives provided are intended as flexible building blocks for your own reusable components.

Improved MCP server in Angular CLI

Version 21 also expands the MCP server integrated into the CLI. It now includes the following tools to assist with AI-assisted programming (Vibe Coding):

  • find_examples: Finds good code examples in a database curated by the Angular team.
  • get_best_practices: Delivers the Angular Best Practice Guide.
  • list_projects: Lists all projects in the current workspace.
  • onpush_zoneless_migration: Helps with migration to Zone-less.
  • search_documentation: Searches the official Angular documentation.
  • ai_tutor: Launches an interactive AI tutor to help you learn Angular. A prompt like "Start AI Tutor" in your Vibe coding tool of choice should be enough to get started.
  • modernize: Helps to migrate existing code to modern Angular. This tool is currently experimental.

The MCP server can be started directly using ng mcp. To also activate experimental tools, the --experimental-tool switch, or -E for short, must be specified. When using code generation tools such as Cursor, Firebase Studio, or Gemini CLI, this command must be configured. Cursor, for example, requires a file named .cursor/mcp.json:

{
  "mcpServers": {
    "angular-cli": {
      "command": "npx",
      "args": ["-y", "@angular/cli", "mcp"]
    }
  }
}

Conclusion

Version 21 continues the modernization of the framework: With Signal Forms and standard zone-less change detection, the focus on responsiveness, developer experience, and performance is strengthened. Vitest replaces the aging Karma and introduces modern testing features such as fake timers, snapshots, and parallelization.

The new package @angular/aria provides a solid foundation for accessible component libraries. Finally, the MCP server, integrated into the CLI, comes with new tools that support vibe coding.

eBook: Modern Angular

Stay up to date and learn to implement modern and lightweight solutions with Angular’s latest features: Standalone, Signals, Build-in Control Flow.

Free Download