Mert Özen Further With Every Line
Frontend Angular — #30

Angular 21: Zoneless by Default, Experimental Signal Forms and Vitest

Mert Özen Oct 31, 2026 7 min 5 views
Angular 21: Zoneless by Default, Experimental Signal Forms and Vitest

In Angular 21 new projects start without Zone.js, Signal Forms arrive as an experiment and become [formField] in 21.1, and Vitest becomes the default test runner. We compare it all with Angular 12.

Version note: This post covers what Angular 21 introduced; the code targets Angular 21.

The Release Where the Defaults Changed

In Angular 20 we saw the signal APIs become stable, met httpResource, and got the new file naming that starts with app.ts. Zoneless also became stable during that version, but it was still a choice. Angular 21, released on November 19, 2025, turns several things that had been in the works for a long time into defaults.

A new project is now born without Zone.js. A brand-new, signal-based approach to forms arrives as an experiment. Tests run with Vitest instead of Karma. On top of that there is a new package for accessibility and a small deprecation that hints at the next version.

In Angular 12, Zone.js Was Everywhere

When we covered change detection earlier, we talked about how Zone.js intercepts browser events, timers and HTTP requests. In an Angular 12 project, all of that came from a single line in src/polyfills.ts:

import 'zone.js';  // Included with Angular CLI.

Before the app started, this line wrapped browser APIs such as setTimeout, addEventListener and XMLHttpRequest. That way Angular knew "something might have happened" and checked the whole component tree. With Angular 21, the app.config.ts generated by ng new looks like this:

import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';

import { routes } from './app.routes';

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

Even the provideZonelessChangeDetection() we had to add for zoneless in Angular 20 is missing here. It is no longer needed, because in Angular 21 zoneless is the default behavior. There is no zone.js dependency in package.json either.

Things are different for an existing app that relies on Zone.js. Such an app now has to say so explicitly: provideZoneChangeDetection() goes into the root providers. The migration that runs during ng update does this for you when needed. In other words, upgrading does not turn your app zoneless overnight.

When Does the Screen Update Without Zones?

That is the first question that comes to mind once Zone.js is gone. Look at this component:

@Component({
  selector: 'app-order-status',
  template: `<p>{{ status }}</p>`
})
export class OrderStatus {
  status = 'Hazırlanıyor';

  constructor() {
    setTimeout(() => this.status = 'Kargoya verildi', 2000);
  }
}

In Angular 12 the screen would update by itself after two seconds, because Zone.js noticed that the setTimeout had finished. In a zoneless app, the screen keeps saying "Hazırlanıyor". The property changed, but nobody told Angular about it.

export class OrderStatus {
  protected readonly status = signal('Hazırlanıyor');

  constructor() {
    setTimeout(() => this.status.set('Kargoya verildi'), 2000);
  }
}

Write {{ status() }} in the template and the problem is solved. When a signal read by the template changes, Angular knows it and refreshes only what needs refreshing. In the zoneless world, the things that set Angular in motion are well defined: changes to signals read by the template, event listeners in the template such as (click), the async pipe, and calls to markForCheck().

Think of a building's caretaker. In the Zone.js world, the caretaker walked every floor at the sound of any door. Zoneless, they only knock on the door of the flat that rang the bell.

Signal Forms: A New Take on Forms

In Angular 12 we built a sign-up form with reactive forms like this:

export class RegisterComponent {
  registerForm = new FormGroup({
    email: new FormControl('', [Validators.required, Validators.email]),
    password: new FormControl('', [Validators.required, Validators.minLength(8)])
  });
}

<form [formGroup]="registerForm">
  <input type="email" formControlName="email">
  <p *ngIf="registerForm.get('email')?.touched && registerForm.get('email')?.invalid">
    Geçerli bir e-posta gir
  </p>
  <input type="password" formControlName="password">
</form>

The data lived inside the form object, values were typed as any, and we reached fields through string keys like get('email'). Here is the same form with Signal Forms, which arrive as an experiment in Angular 21:

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

@Component({
  selector: 'app-register',
  imports: [Field],
  template: `
    <input type="email" [field]="registerForm.email" />
    @if (registerForm.email().touched() && !registerForm.email().valid()) {
      @for (error of registerForm.email().errors(); track error.kind) {
        <p>{{ error.message }}</p>
      }
    }
    <input type="password" [field]="registerForm.password" />
    <p>Model: {{ account().email }}</p>
  `
})
export class Register {
  protected readonly account = signal({ email: '', password: '' });
  protected readonly registerForm = form(this.account, (path) => {
    required(path.email, { message: 'E-posta zorunlu' });
    email(path.email, { message: 'Geçerli bir e-posta gir' });
    minLength(path.password, 8, { message: 'Şifre en az 8 karakter olmalı' });
  });
}

The star of the show is a plain signal: account. The data itself lives there. form() reads the shape of that signal and produces a counterpart for each field; when you write registerForm.email, TypeScript knows that field exists. The function in the second argument defines the validators, and you write the messages right next to each validator.

The [field] directive in the template binds the input to the field. The binding goes both ways: as the user types, the account signal updates, and you can watch it live in the "Model" paragraph on the last line. To reach a field's state you call it like a function: registerForm.email() gives you signals such as touched(), valid() and errors().

In reactive forms, the form was a separate box holding a copy of the data. In Signal Forms, the form is more like a cover fitted over the data. This API is experimental in Angular 21; its shape can change, and indeed it changed soon after.

A Rename in 21.1: [formField]

With version 21.1, the [field] directive got a new name:

import { form, FormField, required, email } from '@angular/forms/signals';

@Component({
  selector: 'app-register',
  imports: [FormField],
  template: `<input type="email" [formField]="registerForm.email" />`
})
export class Register { /* ... */ }

Both the class (FormField instead of Field) and the selector ([formField] instead of [field]) changed, and the old name was removed. The reason is simple: field is such a generic word that the chance of clashing with other components in a project was high. If you move a form written with 21.0 to 21.1, you need to update these lines. That is exactly the price of using an experimental API.

From Karma to Vitest

In Angular 12, running ng test made Karma open a Chrome window and run the tests inside it. The project had karma.conf.js and src/test.ts for that, and the builder in angular.json was @angular-devkit/build-angular:karma. In Angular 21, a new project's test setup is a single entry:

"test": {
  "builder": "@angular/build:unit-test"
}

This builder uses Vitest by default and became stable with Angular 21. The generated spec file looks familiar too:

it('should render title', async () => {
  const fixture = TestBed.createComponent(App);
  await fixture.whenStable();
  const compiled = fixture.nativeElement as HTMLElement;
  expect(compiled.querySelector('h1')?.textContent).toContain('Hello, deneme');
});

describe, it, expect and TestBed are all still there. The noticeable difference is await fixture.whenStable() instead of fixture.detectChanges(); in a zoneless app you wait for Angular to finish its work. By default Vitest runs the tests inside Node with jsdom, without opening a browser, which is why it is fast.

Karma has not been shown the door entirely. You can stay on Karma by adding "runner": "karma" to the builder options, or by creating a new project with ng new --test-runner=karma. To convert existing Jasmine tests there is the ng generate refactor-jasmine-vitest command. One warning: fakeAsync relies on a Zone.js patched for Jasmine, so it can't be used with Vitest; you will need to rework those tests separately.

Angular Aria and Other Notes

Angular 21 brought a new package called @angular/aria. It contains accessible directives for common UI patterns such as accordion, tabs, menu, listbox, combobox and tree. They take care of keyboard interaction, ARIA attributes, focus management and screen reader support; they stay out of the way of appearance and leave styling entirely to you. The package is in developer preview in Angular 21.

A few smaller changes shouldn't slip by. HttpClient is now provided in the root injector by default; you only write provideHttpClient() when you need to customize it, for example to add interceptors. With 21.1, ChangeDetectionStrategy.Default was deprecated, and 21.2 brought ChangeDetectionStrategy.Eager as its replacement name. We will see very clearly why this rename was made in the next version.

Moving to Angular 21: ng update

ng update @angular/core@21 @angular/cli@21

The Node.js requirement is the same as Angular 20: ^20.19.0, ^22.12.0 or ^24.0.0. On the TypeScript side, versions older than 5.9 are no longer supported.

Two important migrations run during the update. If your app relies on Zone.js, provideZoneChangeDetection() is added. If your templates still use *ngIf and *ngFor and you haven't run the control flow migration yet, it is applied with this update as well. Your existing tests keep running on Karma; moving to Vitest is your call.

A Small Experiment

Create a project with npx @angular/cli@21 new deneme. First write the OrderStatus component with a plain property and see that the screen doesn't update. Then turn status into a signal and watch the difference.

Next, build the sign-up form with Signal Forms. An Angular 21 project you create today will be newer than 21.1, so use FormField and [formField] instead of Field and [field]. Watch the "Model" line change as you type into the inputs. Finally, run ng test and see the tests finish without a single browser window opening.

Here we met Signal Forms in their experimental form. Next up is Angular 22, where this API becomes stable along with resources, OnPush becomes the default change detection strategy, and HttpClient switches to Fetch.