The release of Angular 21.2 introduced several significant features - most notably for Signal Forms - while also delivering key enhancements across the broader framework.
Angular 21.2 was released. According to the plan, that was the last minor release in the 21.x series. The next major release will be Angular 22 in May. According to schematic versioning, a minor brings only bug fixes and small features, but there is quite a lot in this release.
Signal Forms
Signal Forms have picked up several useful features that round it out and suggest it is moving toward stability or developer preview.
Form Submission
For example, the need to manually disable form submission is now handled in a simple way. You no longer have to wire this yourself: add the formRoot directive to the form and it turns off the browser default submit behavior (e.g. full page reload).
You configure the submit handler via the submission option of the form() function. That handler runs only when the user actually submits (e.g. Enter or the submit button) and when the form is not invalid.
By default, that means submit can run even while asynchronous validators are still pending.
While an async validator is running, it keeps {invalid: false, valid: false}.
Submit only happens if the invalid is true, so pending validators do not block submission.
You can change this with ignoreValidators: 'none' if you want to wait until all validators, including async, have finished.
The default is permissive because the server will validate again on submit anyway, so there is no need to wait for client-side async validators before allowing the request.
import { bootstrapApplication } from '@angular/platform-browser';
import {
Component, computed, inject, Injectable, signal
} from '@angular/core';
import {
form,
FormField,
FormRoot,
submit,
validateAsync,
validateStandardSchema,
} from '@angular/forms/signals';
@Injectable({ providedIn: 'root' })
export class UserService {
save(user: { firstName: string; lastName: string }) {
return Promise.resolve(undefined);
}
}
@Component({
selector: 'app-root',
imports: [FormField, FormRoot],
template: `
<form [formRoot]="userForm">
<input [formField]="userForm.firstName" />
<input [formField]="userForm.lastName" />
</form>
`,
})
export class App {
private readonly user = signal({
firstName: '',
lastName: '',
});
protected readonly userForm = form(this.user, {
submission: {
action: () => this.userService.save(this.user()),
},
});
protected readonly userService = inject(UserService);
}
bootstrapApplication(App);
Custom Control
For custom controls, we now have the ability to use the transformedValue utility for formatting and parsing.
For example, it can be used for input fields with non-English number or date formats.
import { bootstrapApplication } from '@angular/platform-browser';
import { Component, model, signal } from '@angular/core';
import {
form,
FormField,
FormRoot,
FormValueControl,
transformedValue,
} from '@angular/forms/signals';
import { JsonPipe } from '@angular/common';
@Component({
selector: 'app-german-number-field',
template: `<p><ng-content /></p>
<input
matInput
[value]="rawValue()"
(input)="rawValue.set($event.target.value)"
/>`,
imports: [],
})
export class GermanNumberField implements FormValueControl<number> {
readonly value = model.required<number>();
readonly rawValue = transformedValue(this.value, {
parse: (value: string) => ({ value: Number(value.replace(',', '.')) }),
format: (value) => String(value).replace('.', ','),
});
}
@Component({
selector: 'app-root',
imports: [FormField, FormRoot, GermanNumberField, JsonPipe],
template: `
<form [formRoot]="userForm">
<app-german-number-field [formField]="userForm.heightInMeter">Height in Meters</app-german-number-field>
{{user() | json}}
</form>
`,
})
export class App {
protected readonly user = signal({
heightInMeter: 1.75,
});
protected readonly userForm = form(this.user);
}
bootstrapApplication(App);
SignalFormControl
Although the improved form submission and the advanced features for custom controls are really great, most of us work with an existing Angular application.
For us, the most important new feature is SignalFormControl. It allows you to introduce a FormControl into Reactive Forms that supports, for example, the advanced validation rules from Signal Forms.
That makes the migration to Signal Forms much easier.
import { bootstrapApplication } from '@angular/platform-browser';
import { Component, inject, resource } from '@angular/core';
import { required, debounce, validateAsync } from '@angular/forms/signals';
import { SignalFormControl } from '@angular/forms/signals/compat';
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
@Component({
selector: 'app-root',
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="userForm">
<input formControlName="email" />
</form>
`,
})
export class App {
protected readonly userForm = inject(FormBuilder).nonNullable.group({
email: new SignalFormControl<string>('user@host.com', (path) => {
required(path, { message: 'Email is reqired' });
debounce(path, 500);
validateAsync(path, {
params: (ctx) => ctx.value(),
factory: (params) => {
return resource({
params,
loader: () => Promise.resolve(true),
});
},
onSuccess: () => undefined,
onError: () => ({ kind: 'networkError' }),
});
}),
});
}
bootstrapApplication(App);
ResourceSnapshot
The Resource Snapshot API was finally released. That is a feature that was expected to land in Angular 21 already; it was in the main branch but got reverted.
In short, Resource Snapshots allow you to map one resource to another or use them for compositional tasks.
The example given in the docs changes the default behavior of setting the value to undefined during loading to keeping the original value. In Angular's codebase, the snapshot is used for testing, where it acts as a convenient resource factory function. We will see what use cases are possible.
The example below shows an error handler, which can be used to avoid the error status.
import { bootstrapApplication } from '@angular/platform-browser';
import {
Component,
computed,
Resource,
resource,
resourceFromSnapshots,
signal,
} from '@angular/core';
import { JsonPipe } from '@angular/common';
import { FormField, FormRoot, form } from '@angular/forms/signals';
function withErrorHandler<T>(
resource: Resource<T>,
errorHandler: (error: Error) => T
) {
const res = computed(() => {
const snap = resource.snapshot();
if (snap.status === 'error') {
try {
return { status: 'resolved' as const, value: errorHandler(snap.error) };
} catch (error) {
if (error instanceof Error) {
return { status: 'error' as const, error };
}
}
}
return snap;
});
return resourceFromSnapshots(res);
}
@Component({
selector: 'app-root',
imports: [FormRoot, FormField, JsonPipe],
template: `
<form [formRoot]="idForm">
<input [formField]="idForm.id" type="number"/>
<pre>{{safeUser.value() | json}}</pre>
<pre>{{unsafeUser.value() | json}}</pre>
</form>
`,
})
export class App {
idForm = form(signal({ id: 0 }));
unsafeUser = resource({
params: () => this.idForm.id().value(),
loader: ({ params: id }) => {
if (id < 0) {
throw new Error('no negative users ;)');
}
return Promise.resolve({ id, name: 'John Doe' });
},
});
safeUser = withErrorHandler(this.unsafeUser, () => ({
id: 0,
name: 'John Undefined',
}));
}
bootstrapApplication(App);
CLI - Prettier Integration
As the CLI already provides full integration of Tailwind CSS, Angular 21 also integrates Prettier as a first-class citizen.
Lambdas in Templates
The syntax in the template, which is not native JavaScript, has been extended with two new features. We can now use lambda functions directly in templates.
The favored use case is to call the update method on a signal.
In general, logic should stay out of the template, so do not overuse this feature.
import { bootstrapApplication } from '@angular/platform-browser';
import { Component, signal } from '@angular/core';
@Component({
selector: 'app-root',
template: `
<p>Current Value: {{counter()}}</p>
<button (click)="counter.update((value) => value + 1)">Increment</button>
`,
})
export class App {
counter = signal(0);
}
bootstrapApplication(App);
Exhaustive Checks
The @switch statement now supports exhaustive checking: the compiler verifies that all possible cases are covered when you use @default never.
import { bootstrapApplication } from '@angular/platform-browser';
import { Component, signal } from '@angular/core';
type UserType = 'admin' | 'anonymous';
@Component({
selector: 'app-root',
template: `
@switch(userType) {
@case('anonymous') {
<p>Welcome Visitor</p>
}
@case('admin') {
<p>Welcome admin</p>
}
@default never;
}
`,
})
export class App {
userType: UserType = 'admin';
}
bootstrapApplication(App);
ChangeDetectionStrategy.Eager
As already announced, ChangeDetectionStrategy.Default was renamed to ChangeDetectionStrategy.Eager. The reason is that in Angular 22, in May, the default will become OnPush, and because it does not make sense to call the non-default option "default", its new name is Eager.
You do not have to change anything. If you explicitly use ChangeDetectionStrategy.Default, you can remove that setting or, even better, migrate to OnPush.
You are not forced to change anything. When Angular 22 is released, the update scripts will explicitly set existing implicit or explicit components with ChangeDetectionStrategy.Default to ChangeDetectionStrategy.Eager.
Sources and Thanks
For further information, check out the official changelog and a big thanks to Cedric Exbrayat
His article was the main source for this episode.
Merci beaucoup Cedric!
Top comments (0)