Signal Forms
The experimental Signal Forms API, introduced in Angular 21, offers a fresh approach to form construction that leans on signals. This API strikes a balance between template-driven forms and reactive forms: it delivers the lightweight structure of the former while retaining the control of the latter. What sets it apart is how easy it makes building nested groups (via "Form Groups") and collections that repeat (via "Form Arrays"):

Behind the scenes, a signal holding the intended form structure drives the implementation. The `form` function takes that signal and turns it into a `FieldTree`, which is then used for binding data to the form controls:
@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();
[…]
}
}
At its core, a `FieldTree` is a signal that mirrors the state of a segment of a form—be it an object, an array, or a single property bound to a field. It carries information such as `value`, `dirty`, and `errors`. Every nested property, like `maxLayovers` or `maxPrice`, gets its own `FieldTree` within the tree structure.
To attach a `FieldTree` to an input element, you use the `field` directive:
<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 conciseness, the markup above has been trimmed to only the key parts. When you call `form`, you have the option to pass a schema as its second argument, which is mainly used for validation rules. Standard validators like `required` and `minLength` come out of the box. For custom logic, the `validate` function is your hook:
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(),
};
});
}
Beyond validation, the schema also gives you the power to set up debouncing behavior or to control when certain fields should be disabled.
Diving Deeper into Signal Forms
If you want to explore Signal Forms thoroughly, check out the detailed guide on Angular Architects: "All About Angular's new Signal Forms".
Embracing Zone-less as the Standard
Since its inception, Zone.js has been the engine behind Angular's change detection. This bundled library operates by monkey patching browser primitives like `window`, `document`, and `Promise`. By doing so, it can track when an event handler finishes executing. After that handler runs, Zone.js alerts Angular, prompting it to scan components for any state changes.
However, Zone.js has its downsides. The performance hit of roughly 30 KB when compressed is typically negligible in business apps. But the more pressing issue is how it alters browser objects, which might lead to head-scratching debugging sessions and convoluted stack traces. It also runs the risk of over-triggering change detection, since it has no way to know if the executed handler actually changed any bound data.
With zone-less change detection, that dependency on Zone.js vanishes. Instead, to signal Angular that a value has changed, you'll need to bind to signals or observables. The latter is handled through the *async* pipe as you're used to. To allow Angular (when in *OnPush* mode) to figure out which child components need re-checking, the values held by these reactive structures are expected to be immutable.
In Angular 21, zone-less is the default operational mode. If you'd rather stick with the traditional approach, you can regenerate it by calling `provideZoneChangeDetection` during bootstrap:
// switching back to Zone-full CD
bootstrapApplication(AppComponent, {
providers: [
provideZoneChangeDetection()
],
});
Remember to also include `zone.js` in the polyfills section of your `angular.json` file for this to work.
In principle, zone-less works anywhere `OnPush` does. Still, it's a good idea to run your app through a comprehensive test suite in the new mode. One potential snag is third-party components that were coded with Zone.js assumptions in mind. For those, you might have to either find a newer version or await an update.
Vitest: Transition and Fake Timers
The search for a more modern replacement for the deprecated Karma test runner has ended with the community-driven vitest framework, which the Angular team has adopted. Newly scaffolded projects will automatically have the specialized unit test builder (`@angular/build:unit-test`) configured for them.
Existing projects can hold onto Karma if they wish. For a more automated path, there's an experimental schematic that handles the migration for you:
ng g @schematics/angular:refactor-jasmine-vitest
All unit tests using the new vitest setup run in a zone-less fashion. It follows that moving your app to zone-less is a smart move when you adopt vitest. Since the vast majority of the testing API remains recognizable, your existing Angular testing knowledge translates well to this new environment.
Still, there are a few subtle shifts. Configuring spies, for instance, is slightly different; `spyOn` is now a member of the `vi` namespace:
vi.spyOn(flightService, 'find');
Setting this up depends on your config: `vi` automatically becomes global, or you may need to import it explicitly:
import { vi } from 'vitest';
With vitest, spies automatically delegate to the method they're wrapping, so the Jasmine convention of calling `.and.callThrough()` is no longer part of the routine. The object that `spyOn` returns has a slightly altered shape, although its core purpose remains. As an example, installing a mock return value looks like this:
vi.spyOn(flightService, 'find')
.mockImplementation((_from, _to) => of([]));
Beyond these tweaks, vitest borrows a host of features from the Jest ecosystem. Expect to see snapshot testing, robust module mocking, and the ability to run tests in parallel.
A crucial detail is that since vitest operates in a zone-less mode within Angular, the trusty Zone.js test utilities—like `fakeAsync` and `tick`—are no longer available. Tests that relied on them need a revamped strategy. A typical workaround is to swap asynchronous operations for synchronous mocks, a pattern you may recognize from testing with `HttpClient`.
Should you need to manipulate time during your tests, vitest's fake timer implementation steps in to fill the role:
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);
},
};
}
In this snippet, the `beforeEach` hook activates fake timers for each test case in the group. After the test sets up its content, it must trigger the debounce timers to flush before it can assert the new value, which is accomplished with `runAllTimersAsync`.
Prefer this async variant over the synchronous `runAllTimers`? Using `runAllTimersAsync` does more than just fire every timer; it also awaits the microtasks that timers often spawn, like those born from Promises inside a timer's callback.
Vitest Browser Mode
By design, vitest deviates from Karma by executing tests in a Node.js environment as opposed to a real browser. To supply a simulated DOM, it uses either the `happy-dom` or `jsdom` package, both of which require explicit installation. If both are available, `happy-dom` is the one it picks. Although this method offers a performance boost, the tests are running in a synthesized environment.
Alternatively, vitest ships a browser mode that bypasses the simulation. In this mode, a designated provider facilitates the link with the actual browser. The Playwright provider is the go-to choice from the vitest team, mainly for the parallelism it brings to test execution:
npm install @vitest/browser-playwright -D
Switching on browser mode is done via the `angular.json` configuration, where you list the browsers you intend to target:
"test": {
"builder": "@angular/build:unit-test",
"options": {
"browsers": ["Chromium"]
},
"configurations": {
"ci": {
"browsers": ["ChromiumHeadless"]
}
}
}
The setup above points to Chromium. It also defines a `ci` configuration that leverages a headless Chrome for build server environments. During its operation, tests interact with the page through a special `page` object supplied by vitest:
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();
});
Element lookup is conducted via ARIA attributes. For instance, calling `getByLabelText('from')` seeks an element bearing `aria-label="from"`, and using `getByRole('button', { name: 'search' })` pinpoints a button with an `aria-label` of "search". Note that the `name` option in this context aligns with the `aria-label`, not the HTML `name` attribute.
In the sample test, the `page` object enables simulation of user gestures. It exposes methods geared toward interaction, such as `fill`. Cleverly named methods that weren't shown include `clear`, and the standard suite like `click`, `dblClick`, `hover`, and `unhover`. For an even broader range of events, the `userEvent` object from the `vitest/browser` package has got you covered.
Executing *ng test* in browser mode triggers the CLI to launch your specified browser instances and run the suite right inside them:

The panel in the middle offers a live look at the component under test. Post-test, this area's DOM is wiped clean so the next test won't be impacted by leftovers. Should you be debugging, using the browser's developer tools with breakpoints, you'll see the application's current state frozen in time at that location, which can be uniquely helpful for tracking down elusive problems.
Learn More About Testing
For those wanting to refine their testing skills further, the Professional Angular Testing workshop covers the ground in depth:
Angular Aria
Angular's brand-new `@angular/aria` package offers a suite of directives that put WAI-ARIA patterns into practice. Right from the start, you get built-in handling for keyboard navigation, ARIA attributes, managing focus, and working with screen readers. These directives are crafted as architectural bits for building component libraries with a custom look and feel, not as components to plug directly into an app.
The visual below gives a quick tour of the directives included in this first release. They are out-of-the-box headless, so to make them look like a real interface, the styling from *angular.dev* has been applied to the tree view shown:

Check out this somewhat condensed example straight from the official docs; it puts together a functional 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's that grid in action:

The grid is formed by interlocking directives: `Grid`, `GridRow`, `GridCell`, and `GridCellWidget`. The sheer size of this code for something as straightforward as a grid highlights their purpose: these are modular, do-it-yourself components for crafting your own bespoke UI elements.
Enhanced MCP Server within Angular CLI
The integrated MCP server gets a hefty upgrade in version 21. It comes ready with a suite of tools to power up the AI-assisted (Vibe Coding) workflow:
- find_examples: Locates strong code samples from a curated database maintained by the Angular team.
- get_best_practices: Hands over the Angular Best Practice Guide.
- list_projects: Shows all projects within your current workspace.
- onpush_zoneless_migration: Aids the shift to Zone-less change detection.
- search_documentation: Queries the official Angular documentation.
- ai_tutor: Engages an interactive AI tutor to assist in learning Angular. Kicking things off is as easy as prompting "Start AI Tutor" in your vibe coding setup.
- modernize: Assists in updating old code to work with current Angular standards. This particular tool is presently in its experimental phase.
To start the MCP server, you'd run `ng mcp`. If you want the experimental tools exposed, add the `--experimental-tool` (or `-E` for short) switch. When you're teaming up with code gen tools like Cursor, Firebase Studio, or Gemini CLI, you'll need to register this command. Taking Cursor as a case, you'd create a `.cursor/mcp.json` file:
{
"mcpServers": {
"angular-cli": {
"command": "npx",
"args": ["-y", "@angular/cli", "mcp"]
}
}
}
Wrapping Up
The newest Angular release carries forward the overhaul by doubling down on reactivity, DX, and speed with signal-driven forms and zone-less as the standard approach. Vitest is set to phase out Karma, bringing along such modern perks as fake timers, snapshots, and parallel test runs.
For libraries centered on accessibility, `@angular/aria` lays a tremendous groundwork. And lastly, the CLI's built-in MCP server sees new capabilities aimed at boosting AI-driven coding sessions.


