Zoneless becomes the standard
As highlighted in an earlier piece, the zoneless change detection mechanism reached a stable milestone with Angular 20.2. Angular 21 takes this further by making zoneless the default choice for newly created applications. To support existing projects, the team has also shipped schematics that guide developers through the process of migrating their current setups to this new mode.
It's safe to assume that most Angular developers have encountered Angular Signals by now. For anyone who hasn't yet, familiarizing yourself with them should be a priority — our article on the topic is a good starting point.
The same holds true for Angular Forms. Before proceeding, make sure you understand how signals operate at a fundamental level. With that foundation in place, we can turn our attention to an exciting new feature introduced in this release.
Signal Forms
Consider a straightforward reactive form set up in the traditional way:
export class App implements OnInit {
private readonly _fb = inject(FormBuilder);
protected form!: PersonForm;
ngOnInit() {
this.initForm();
}
protected onSubmit() {
if (this.form.valid) {
console.log('Form submitted:', this.form.value);
}
}
private initForm() {
this.form = this._fb.group({
name: this._fb.nonNullable.control('',
[
Validators.required,
Validators.minLength(3)
]
),
surname: this._fb.nonNullable.control('',
[
Validators.required,
Validators.maxLength(10)
]
),
telephoneNumber: this._fb.control(
null,
Validators.required
),
});
}
This form can then be leveraged directly within the template:
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div>
<label>
Name:
<input type="text" formControlName="name" />
</label>
@if(form.controls.name.invalid && form.controls.name.touched) {
<p>Name is required</p>
}
</div>
<div>
<label>
Surname:
<input type="text" formControlName="surname" />
</label>
@if(form.controls.surname.invalid && form.controls.surname.touched)
{
<p>Surname is required</p>
}
</div>
<div>
<label>
Telephone Number:
<input type="number" formControlName="telephoneNumber"/>
</label>
@if(form.controls.telephoneNumber.invalid &&
form.controls.telephoneNumber.touched
) {
<p>Telephone number is required</p>
}
</div>
<button type="submit" [disabled]="form.invalid">Submit</button>
</form>
<pre>{{ form.value | json }}</pre>
`,
As the example illustrates, we've defined a basic form containing three fields: name, surname, and telephone number. In the UI, we inspect whether a user has touched a control and subsequently check the validation status of that field. When validation fails, an error message is shown. Let’s examine how this process changes with the latest Angular update.
First, we need to modify the component class:
protected readonly person = signal<PersonForm>({
name: '',
surname: '',
telephoneNumber: null
})
protected readonly personForm = form(this.person);
Notice that we’ve established a signal which functions as the source model for our form. The `OnInit` lifecycle hook has been discarded since it is no longer required. The `form()` function represents the new API that ships with Angular 21. A key point to remember is that any changes made to `personForm` are automatically synchronized back to the form model signal:
changePersonName(value: string) {
this.personForm.name().value.set(value);
console.log(this.person()); // {name: 'John', surname: '', telephoneNumber: null}
}
Shifting our attention to the template, the `field` directive is essential here as it links UI components to the form fields. Once this directive is correctly imported, the template can be updated to the following state:
template: `
<form (ngSubmit)="onSubmit()">
<div>
<label>
Name:
<input [field]="personForm.name" type="text"/>
</label>
</div>
<div>
<label>
Surname:
<input [field]="personForm.surname" type="text"/>
</label>
</div>
<div>
<label>
Telephone Number:
<input [field]="personForm.telephoneNumber" type="number" />
</label>
</div>
<button type="submit" [disabled]="personForm().invalid()">Submit</button>
</form>
<pre>{{ personForm().value() | json }}</pre>
`,
The simplicity might be surprising at first glance. We deliberately stripped out the error handling from this snippet to highlight the fundamental changes in the mechanism. With the new approach, iterating over possible validation errors and displaying the ones that are present is incredibly straightforward.
protected readonly personForm = form(this.person, (path) => {
required(path.name);
required(path.surname);
minLength(path.name, 3);
maxLength(path.surname, 40);
});
Here’s the pattern for displaying those errors:
<div>
<label>
Name:
<input [field]="personForm.name" type="text" />
</label>
@for(err of personForm.name().errors(); track $index) {
@if(err.kind === 'required') {
<p>Name is required</p>
}
}
</div>
If you're curious about the inner workings of `personForm`, there's no trickery involved. The validation logic is encapsulated within a schema function. New utility validators like `required()` are also part of the package. The validators receive their configuration path from the argument passed to the schema function.
It's important to note that Signal Forms are currently in an experimental phase. We should expect the API and its behavior to evolve leading up to a stable release.
Angular Aria – a fresh UI component library
Accessibility requirements are playing a bigger role in the daily development routine. These are no longer just optional guidelines; for apps targeting the European market, compliance with the standards detailed in this reference is mandatory: https://angular.love/digital-accessibility-2025-how-to-avoid-fines-and-win-more-users.
To address this growing need, the Angular team has unveiled Angular Aria, a new UI library designed for building accessible interfaces. It joins Angular Material and the CDK as another valuable option for developers. At this stage, the library is available in a developer preview.
Adding this library to an existing project is a simple command away:
npm install @angular/aria
For comprehensive information on the library, consult the official announcement here: https://blog.angular.dev/announcing-angular-v21-57946c34f14b.
SimpleChanges receives generic type support
Angular 21 upgrades `SimpleChanges` to a generic type. This allows developers to specify the exact data type for each `@Input()` property. The benefit is that TypeScript can now perform stricter type checking within the `ngOnChanges` hook. In previous versions, `SimpleChange` offered no type safety for the `previousValue` and `currentValue` properties, leaving them as `any` and offering no compile-time safeguards. Here’s a look at the new type-safe behavior.
export interface User {
userName: string;
age: number;
}
@Component({
//…//
})
export class App {
@Input({required: true}) userName!: string;
@Input({required: true}) age!: number;
ngOnChanges(changes: SimpleChanges<User>) {
if (changes.age) {
const newAge = changes.age.currentValue;
const oldAge = changes.age.previousValue;
const diff = newAge - oldAge;
console.log(`Age increased by ${diff} years`);
}
}
}
HttpClient now available by default
With this latest version, manually providing `HttpClient` in the application is a thing of the past. It’s now injected by default. This means that when setting up the application configuration, you can omit the `HttpClient` provider without any issues.
// import { provideHttpClient } from `@angular/common`
export const appConfig: AppConfig = {
providers: [
...anotherProviders,
// provideHttpClient()
]
}
Migrating NgClass to style bindings with a new schematic
While the use of `ngClass` is allowed, it's generally discouraged in favor of better alternatives. To facilitate this transition, the Angular team has developed a schematic that can automatically refactor all instances of `ngClass` into the more modern `class` bindings.
The directive usage before the migration takes this form:
@Component({
//…//
imports: [NgClass],
template: `
<button [ngClass]="{
'isNew': isNew()
}">Click me</button> //before migration
`,
})
export class App {
protected readonly isNew = signal(true);
}
Once the migration schematic has been applied, the changes are clear:
@Component({
//…//
// imports: [NgClass] - is’s no longer needed
template: `
<button [class]="{
'isNew': isNew()
}">Click me</button> //after migration
`,
})
export class App {
protected readonly isNew = signal(true);
}
As a result, the `NgClass` import is no longer necessary. This not only trims the bundle size but also simplifies the codebase, making it more readable. To execute this migration manually within a project, run the following command:
ng generate @angular/core:ngclass-to-class
Are you planning an upgrade or trying to stay informed about recent developments? We've put together an exhaustive guide covering the evolution of Angular from version 14 up to the current release to assist developers and decision-makers. Get your free copy of “The Ultimate Guide to Angular Evolution” now.
New schematic for migrating NgStyle to style bindings
In the same vein as the `ngClass` migration, there's a dedicated schematic to handle the `ngStyle` directive, transitioning it to the newer style binding syntax.
For reference, the pre-migration code appears as follows:
@Component({
//…//
imports: [NgStyle],
template: `
<button [ngStyle]="{
'border-color': borderColor(),
}">Click me</button> //before migration
`,
})
export class App {
readonly theme = input.required<ColorTheme>();
protected readonly borderColor = computed(() => this.theme() === 'primary' ? 'rgba(0, 0, 0, 0.1)' : 'rgba(0, 0, 0, 0.5)' ));
}
And the resulting code after the migration is:
@Component({
//…//
// imports: [NgStyle], - is’s no longer needed
template: `
<button [style]="{
'border-color': borderColor(),
}">Click me</button> //before migration
`,
})
export class App {
readonly theme = input.required<ColorTheme>();
protected readonly borderColor = computed(() => this.theme() === 'primary' ? 'rgba(0, 0, 0, 0.1)' : 'rgba(0, 0, 0, 0.5)' ));
This process also eliminates the need for the directive’s import statement.
The migration can be initiated with this command:
ng generate @angular/core:ngstyle-to-style
KeyValue pipe now accepts optional keys
The latest Angular release permits the `keyvalue` pipe to be used on objects that contain optional keys without triggering TypeScript errors. This adjustment enhances type safety and simplifies interactions with data models where certain properties may not always be defined.
export interface User {
name: string;
surname?: string;
age?: number;
}
@Component({
selector: 'app-root',
imports: [KeyValuePipe],
template: `
@for (prop of user | keyvalue; track $index) {
<p>Property key: {{ prop.key }}, property value: {{ prop.value }}</p>
}
`,
})
export class App {
protected readonly user: User = {
name: 'John',
surname: 'Doe',
age: 37
};
}
Improved HttpResponse and HttpErrorResponse
A new `responseType` property has been introduced to the `HttpResponse` and `HttpErrorResponse` classes in this version. This property reveals the underlying response type from the Fetch API, such as ‘basic’, ‘cors’, ‘opaque’, or ‘opaqueredirect’.
This addition aids in diagnosing problems related to CORS and offers developers a clearer view of the security context for their HTTP responses. Importantly, this is a purely additive feature, maintaining the existing `HttpClient` behavior without any modifications.
@Injectable({ providedIn: 'root' })
export class DataService {
private readonly _httpClient = inject(HttpClient);
getData(): Observable<HttpResponse<any>> {
return this.http.get('/api/data', { observe: 'response' }).pipe(
tap(response => {
console.log('Response type:', response.responseType);
if (response.responseType === 'opaque') {
console.warn('CORS issue detected — response is opaque.');
}
})
);
}
}
Vitest steps in as the default testing framework
Vitest is now the designated default test runner for projects created with Angular 21, and the integration has been declared fully stable with this release. If you're currently using Karma or Jest, upgrading Angular won't break your existing test suite, as support for these tools remains in place.
Nevertheless, the Angular team has ready an experimental migration path. To use it, execute the following command in your terminal:
ng g @schematics/angular:refactor-jasmine-vitest
Complete instructions for the migration process can be found in the official documentation:
https://angular.dev/guide/testing/migrating-to-vitest.
Conclusion
For those still on an earlier version, upgrading to Angular 21 seems like a wise move. This release brings a host of notable improvements, with the introduction of Signal Forms being a standout feature. Collectively, these changes elevate the developer experience and contribute to performance gains in numerous situations. For a more detailed breakdown of the changes in this release and how to best utilize them, our comprehensive coverage of Angular's recent evolution is a valuable resource.



