Introduction
La sortie d’Angular 21 le 20 novembre 2025 dernier (Angular v21 Event), marque une étape majeure pour le framework. Cette version introduit des fonctionnalités expérimentales, des améliorations de performance et des outils modernes qui facilitent l’écriture de code front-end réactif et plus rapide.
Cette version rend le zoneless accessible par défaut pour les nouveaux projets, ce qui réduit la surcharge liée à ZoneJS et accélère nettement les cycles de détection de changements. Associées aux Signal Forms, ces évolutions améliorent la fluidité des interfaces et la prévisibilité des performances.

Autre changement important : Vitest devient le runner de test privilégié pour les nouveaux projets, offrant des retours beaucoup plus rapides et une intégration naturelle avec l’écosystème moderne (TypeScript / ESM / Vite). Dans ce guide, nous passons en revue ces nouveautés (Signal Forms, HttpClient, router, RegEx en templates, Vitest, migrations, zoneless) avec des exemples et des conseils pratiques pour préparer la migration.
Table des Matières
Signal form
❓Qu'est-ce que les Signal Forms ?
Avec Angular 21, une nouvelle ère s’ouvre pour la gestion des formulaires : les Signal Forms.
Cette API expérimentale réinvente la logique de formulaire s’appuyant sur les signals natifs plutôt que les FormGroup et FormControl classiques. Elle offre une API plus déclarative, typée et réactive, où le formulaire devient une simple projection de votre modèle.
💡 Le coeur du système
La fonction form relie directement un modèle de données (représenté par un WritableSignal) à une structure de formulaire réactive. Elle se décline sous plusieurs signatures selon le besoin.
1️⃣ Formulaire simple
form<TValue>(model: WritableSignal<TValue>): FieldTree<TValue>
Crée un formulaire de base à partir d’un signal.
readonly model = signal({firstName: '', lastName: ''});
readonly form = form(model);2️⃣ Avec validation ou options
form<TValue>(model: WritableSignal<TValue>, schemaOrOptions: SchemaOrSchemaFn<TValue> | a): FieldTree<TValue>
Cette forme permet d'ajouter un schéma de validation ou des options de configuration au formulaire. Le schéma peut être une fonction qui définit les règles de validation directement, ou un objet schema prédéfini.
readonly form = form(signal({firstName: '', lastName: ''}), (path) => {
required(path.firstName);
required(path.lastName);
pattern(path.lastName, /^[a-z]+$/i, { message: 'Alphabet characters only' });
});3️⃣ Avec schéma et options
form<TValue>(model: WritableSignal<TValue>, schema: SchemaOrSchemaFn<TValue>, options: FormOptions): FieldTree<TValue>
C’est la version la plus complète, combinant validation et configuration.
readonly talkRegistrationForm = form<TalkRegistration>(this.talkRegistration,
this.#talkRegistrationSchema
{
// Fournit un contexte d’injection personnalisé
injector: this.#customInjector,
// Donne un identifiant lisible au formulaire, pratique pour le debug, les logs ou les outils de dev
name: 'talk-registration',
// Permet de personnaliser la façon dont les FieldTree et FieldState sont construits. C’est un mécanisme avancé pour modifier le comportement ou le rendu des champs
adapter: this.#customFieldAdapter
}
);e
🔄 Synchronisation parfaite
Contrairement à l'ancienne API ReactiveFormsModule qui maintient une copie interne des valeurs (FormGroup, FormArray et FormControl), form() s’appuie directement sur le signal d’origine : il ne duplique pas les données.
Autrement dit, le formulaire et le modèle partagent le même état. Chaque changement dans l’un se reflète instantanément dans l’autre.
this.formModel.firstName().value.set('John');
this.formModel().value(); // { firstName: 'John', lastName: '' }
this.model(); // { firstName: 'John', lastName: '' }
this.model.update((value) => ({ ...value, lastName: 'Bernard' }));
this.formModel().value(); // { firstName: 'John', lastName: 'Bernard' }
this.model(); // { firstName: 'John', lastName: 'Bernard' }
👉 Aucune synchronisation nécessaire, pas de patchValue ni de setValue. Le formulaire est le modèle.
📦 FieldTree et propriétés exposées
La fonction form() renvoie un FieldTree, c’est-à-dire une arborescence où chaque propriété du modèle devient un FieldState.
Chaque champ du formulaire est ainsi encapsulé dans un petit objet réactif contenant toutes les informations essentielles, exposées sous forme de signals :
| Accès global (form()...) | Rôle |
|---|---|
|
Valeur du champ. |
|
Indique si le champ est non modifiable. |
|
Liste des erreurs. |
|
Indique si la valeur du champ est valide ou invalide. |
|
Indique si la valeur du champ a été modifiée par l'utilisateur. |
|
Indique si le champ a été touché par l'utilisateur. |
|
Indique si submit(...) est en cours. |
|
Indique si le champ est visible. |
|
Donne les raisons expliquant pourquoi les champs sont désactivés. |
|
Indique si des validateurs asynchrones sont en cours. |
Avec l’arrivée des Signal Forms, Angular introduit de nouvelles propriétés qui apportent davantage de clarté et de souplesse dans la gestion des champs.
Ces signaux supplémentaires permettent de gérer plus finement la visibilité, l’état d’envoi ou encore les raisons d’un champ désactivé.
🫥 hidden() - Exclure logiquement un champ
La propriété hidden() permet d’exclure un champ dans l’état global du formulaire (validity, touched, dirty…), mais n'est pas caché automatiquement dans le template. C’est donc au développeur de gérer son affichage selon le besoin :
@if (!talkRegistrationForm.beginnerFriendlyDescription().hidden()) {
<textarea ...></textarea>
}Exemple de règle dynamique :
hidden(path.beginnerFriendlyDescription, ({ valueOf }) =>
!valueOf(path.isBeginnerFriendly)
);🚀 submitting() - Suivi d’un envoi
submitting() passe automatiquement à true pendant l’exécution d’un submit(form, callback). Idéal pour afficher un état de chargement ou désactiver le bouton d’envoi :
submit(this.talkRegistrationForm, async (form) => {
// submitting() === true ici
await saveData(form().value());
});<button [disabled]="talkRegistrationForm().submitting() || talkRegistrationForm().disabled()">
@if (talkRegistrationForm().submitting()) {
Saving...
} @else {
Submit
}
</button>Plus besoin de déclarer manuellement un isLoading ou isSubmitting ! 🎉
⏳ pending() - Validateurs asynchrones en cours
pending() devient true lorsqu’un validateur asynchrone est en cours d'exécution, par exemple lors d'une vérification d’email côté serveur :
validateAsync(path.email, {
params: ({ value }) => ({ email: value() }),
factory: (params) => {
return httpResource<{ isValid: boolean }>(() => `/api/validate-email?email=${encodeURIComponent(params().email)}`);
},
errors: (result, ctx) => {
if (!result.isValid) {
return {
kind: 'email-taken',
message: `The email address "${ctx.field().value()}" is already registered.`
};
}
return null;
}
});@if (talkRegistrationForm.email().pending()) {
<span>Checking email availability...</span>
}🚫 disabledReasons() - Pourquoi un champ est désactivé ?
disabled() indique simplement que le champ est inactif. disabledReasons(), quant à lui, fournit la ou les raisons de cette désactivation.
disabled(path.talkDate,
({ valueOf }) => (!valueOf(path.firstName)
? 'First name is required before selecting a date'
: false));
@for (reason of talkRegistrationForm.talkDate().disabledReasons(); track reason) {
<span class="reason">{{ reason.message }}</span>
}🧩 Validators disponibles
Avec Signal Forms, Angular propose une nouvelle approche de validation entièrement repensée. Elle repose sur des fonctions simples (required, minLength, email, etc.) que l’on applique directement sur le chemin d’un champ via path. Chaque règle est déclarée de manière explicite, au même endroit que la logique du formulaire
required(path: FieldPath)
Le validateur required() rend un champ obligatoire. Si la valeur est vide ('', null, undefined), il génère une erreur structurée :
{ "kind": "required" }required(path.firstName);minLength(path: FieldPath, minLength: number) et maxLength(path: FieldPath, maxLength: number)
Les validateurs minLength() et maxLength() contrôlent respectivement la longueur minimale et maximale d’un champ texte (ou d’un tableau).
minLength(path.talkDescription, 10);
maxLength(path.talkDescription, 125);Ils injectent automatiquement les attributs HTML minlength et maxlength sur l’élément, ce qui provoque parfois un blocage natif du navigateur : impossible de saisir au-delà de la limite, empêchant aussi l’erreur de s’afficher visuellement.
Pour tester ce validateur, il faudra supprimer manuellement l’attribut dans le DOM afin de laisser l’utilisateur dépasser la limite et déclencher le message d’erreur Angular. L’attribut sera toutefois réinséré automatiquement à la prochaine interaction.
En pratique, il est souvent préférable d’opter pour une solution UX plus douce — par exemple afficher un compteur de caractères :
<p>{{ talkRegistrationForm.talkDescription().value().length }} / 125 caractères</p>Erreurs générées :
{ "kind": "minLength", "minLength": 2 }
{ "kind": "maxLength", "maxLength": 125 }min(path: FieldPath, min: number) et max(path: FieldPath, max: number)
Les validateurs min() et max() s’adressent aux valeurs numériques, contrairement à minLength et maxLength qui concernent les chaînes ou tableaux. Leur fonctionnement reste très similaire : on définit une borne minimale ou maximale, l’API injecte l’attribut HTML (min, max) sur le champ numérique, et la validation échoue si la valeur sort des bornes définies. Ils renvoient également des objets d’erreur structurés :
{ "kind": "min", "min": 18 }
{ "kind": "max", "max": 65 }
pattern(path: FieldPath, pattern: RegExp)
Utilisez pattern() pour valider une chaîne de caractères selon une expression régulière. Ce validateur est idéal pour des formats d’entrée précis : nom d’utilisateur, code produit, etc. Erreur retournée :
{ "kind": "pattern", "pattern": {} }
email(path: FieldPath)
Pour les champs adresses électroniques, email() propose une validation dédiée, sans avoir à composer manuellement la RegExp. L’erreur retournée est représentée par :
{ "kind": "email" }♻️ Réutiliser des validations avec schema
Définir toutes les validations directement dans chaque form() peut vite devenir verbeux et difficile à maintenir.
C’est là qu'intervient la fonction schema() , qui permet de factoriser et réutiliser des ensembles de règles sur plusieurs formulaires.
import { schema, required, email, maxLength } from '@angular/forms/signals';
readonly userSchema = schema<{ name: string; email: string }>((path) => {
required(path.name);
maxLength(path.name, 50);
required(path.email);
email(path.email);
});Une fois le schéma défini, il peut être appliqué à n’importe quel form() qui partage la même structure de modèle :
readonly user = signal({ name: '', email: '' });
readonly userForm = form(user, userSchema);Résultat : toutes les règles définies dans le schema() s’appliquent automatiquement au formulaire. C’est un moyen centralisé et réutilisable de gérer la logique métier.
Une fois qu’on a défini plusieurs schémas réutilisables, Angular fournit des utilitaires pour les combiner intelligemment. Ces fonctions (apply(), applyEach(), applyWhen() et applyWhenValue()) permettent d’assembler la logique métier sans duplication, tout en gardant la réactivité native des signals.
🧩 apply() — réutiliser un schéma dans un autre
La fonction apply() permet d’appliquer un schéma à une sous-partie précise du formulaire ou d'un autre schéma.
readonly #userSchema = schema<{ firstName: string; lastName: string; email: string }>((path) => {
required(path.firstName);
maxLength(path.firstName, 50);
required(path.lastName);
pattern(path.lastName, /^[a-z]+$/i);
required(path.email);
email(path.email);
});
readonly #talkRegistrationSchema = schema<TalkRegistration>((path) => {
apply(path, this.#userSchema);
});Ici, on ré-injecte simplement toutes les règles de userSchema dans le schéma principal talkRegistrationSchema.
🔁 applyEach() — appliquer un schéma à chaque élément du tableau
Lorsqu’un modèle contient une liste (par exemple une liste de speakers), applyEach() permet d’appliquer un schéma commun à chaque élément du tableau :
readonly #speakerSchema = schema<Speaker>((path) => {
required(path.firstName);
maxLength(path.firstName, 50);
required(path.lastName);
pattern(path.lastName, /^[a-z]+$/i);
min(path.age, 18);
max(path.age, 99);
required(path.email);
email(path.email, {
error: ({ value }) => customError({ kind: 'email', message: `"${value()}" must be a valid email address.` })
});
});
readonly #talkRegistrationSchema = schema<TalkRegistration>((path) => {
applyEach(path.speakers, this.#speakerSchema);
// autres règles...
});
readonly talkRegistrationForm = form<TalkRegistration>(this.talkRegistration, this.#talkRegistrationSchema);Chaque entrée du tableau speakers devient alors un FieldTree validé selon speakerSchema.
Ajout et suppression d’un élément :
addSpeaker() {
this.talkRegistrationForm.speakers().value.update(state => [
...state,
{
firstName: '',
lastName: '',
age: 18,
email: ''
}
]);
}
removeSpeaker(index: number) {
this.talkRegistrationForm.speakers().value.update(state => state.filter((_, i) => i !== index));
}⚡ applyWhen() — appliquer un schéma conditionnellement
applyWhen() permet d’appliquer des règles uniquement lorsque certaines conditions sont réunies. La condition est réactive : elle s’adapte automatiquement aux changements de valeur du formulaire.
applyWhen(path,
({ value }) => value().isBeginnerFriendly,
(subpath) => {
required(subpath.beginnerFriendlyDescription, {
message: 'Please describe why your talk is beginner-friendly.'
});
});👉 Ici, la validation required() ne s’applique que si isBeginnerFriendly vaut true. C’est un excellent moyen d’éviter d’avoir à manipuler manuellement les validateurs dans le code.
⚠️ Attention à l’utilisation du path
Lorsqu’on utilise applyWhen() le callback reçoit un nouveau FieldPath (ici nommé subpath). Il est important de réutiliser ce dernier et de ne pas réutiliser le path parent à l’intérieur de ce bloc. Si tu réutilises le path du parent à l’intérieur du sous-schéma, Angular détecte que tu essaies d’accéder à un champ en dehors de la portée actuelle du schéma et boom l'erreur suivante apparaît. 💥
Error: A FieldPath can only be used directly within the Schema that owns it, not outside of it or within a sub-schema.
🎯 applyWhenValue() — condition basée sur une valeur dérivée
Semblable à applyWhen(), mais ici la condition se base sur la valeur d’un champ précis, et non sur tout le modèle.
applyWhenValue(path.isBeginnerFriendly,
(value) => value,
() => required(path.beginnerFriendlyDescription));
💬 Messages et erreurs personnalisés
Les Signal Forms ne se limitent pas à la validation — elles introduisent une manière moderne et typée de gérer les messages d’erreur et d'information.
Chaque validateur peut définir un message fixe via l’option message , ou une erreur plus riches avec customError(). Le tout repose sur un contexte (ctx) passé à chaque validateur, offrant un accès direct au champ, à sa valeur, et à ses propriétés.
1️⃣ Utilisation simple avec l’option message
Pour des messages fixes et simples, il suffit de passer message au validateur :
required(path.beginnerFriendlyDescription, {
message: 'Please describe why your talk is beginner-friendly.'
});Ce validateur crée automatiquement un objet ValidationError avec :
{
"kind": "required",
"field": "<FieldTree>", // le FieldTree correspondant au champ en erreur
"message": "Please describe why your talk is beginner-friendly."
}2️⃣ Utilisation de customError() pour plus de flexibilité
Pour des messages plus complexes ou dépendant d’autres propriétés du champ, customError() permet de construire des erreurs entièrement personnalisées.
import { customError, MAX_LENGTH, maxLength } from '@angular/forms/signals';
maxLength(path.talkDescription, 10, {
error: ({ field }) => customError({
kind: 'max-length-with-property',
message: `Talk description cannot exceed ${field().metadata(MAX_LENGTH)()} characters.`
})
});Ici, on exploite field().metadata(MAX_LENGTH)() pour récupérer dynamiquement la contrainte appliquée. Le message s’adapte donc automatiquement à la règle de validation — plus besoin de répéter la valeur du maxLength dans le texte.
💡 customError() te permet aussi de définir ton propre kind, ce qui est très utile pour filtrer ou styliser des messages d’erreur spécifiques dans le template.
📏 Liste des métadonnées disponibles
Angular expose plusieurs métadonnées permettant d’accéder dynamiquement aux métadonnées d’un champ : ses contraintes, longueurs, patterns, etc. Elles sont accessibles depuis field().metadata(...)() .
| Métadonnée | Description |
|---|---|
|
Indique si le champ est requis. |
|
Représente la valeur minimale autorisée pour un champ numérique. |
|
Représente la valeur maximale autorisée pour un champ numérique. |
|
Définit la longueur minimale d’un texte ou d’un tableau. |
|
Définit la longueur maximale d’un texte ou d’un tableau. |
|
Contient la liste des expressions régulières appliquées au champ. |
3️⃣ Validation conditionnelle avec when
Certains champs ne sont requis que dans des contextes précis. Avec when , on peut déclarer cette dépendance directement dans le validateur :
required(path.beginnerFriendlyDescription, {
when: ({ valueOf }) => valueOf(path.isBeginnerFriendly),
message: 'Please provide a description for beginner-friendly talks.'
});👉 Dans cet exemple, la description n’est obligatoire que si isBeginnerFriendly est cochée (true). Le validateur s’active et se désactive automatiquement selon la valeur du champ.
Rendu des erreurs dans le template
Les erreurs d’un champ sont disponibles via form.field().errors(). Chaque élément de cette liste est un objet ValidationError, qu'il est possible de parcourir facilement avec un @for :
@for (error of talkRegistrationForm.talkTitle().errors(); track error.kind) {
@if (error.kind === 'required') {
<span class="error">Talk title is required.</span>
} @else if (error.message) {
<span class="error">{{ error.message }}</span>
}
}📨 Soumettre un formulaire
La validation côté client, c’est bien. Mais dans la vraie vie, il faut aussi gérer les retours du serveur — par exemple lorsqu’un titre de talk est déjà pris. Avec Signal Forms, Angular simplifie cette étape grâce à la fonction submit().
Elle relie directement la logique de soumission du formulaire à ton modèle et applique automatiquement les erreurs retournées par le serveur sur les bons champs.
Dans l'exemple ci-dessous, on commence par simuler un service côté serveur :
function fakeTalkService() {
return {
async registerNewTalk(data: TalkRegistration): Promise<{ success: boolean; errorCode?: string }> {
return new Promise((resolve) => {
setTimeout(() => {
if (data.talkTitle.toLowerCase() === 'angular') {
resolve({ success: false, errorCode: 'TITLE_TAKEN' });
} else {
resolve({ success: true });
}
}, 1000);
});
}
};
}Dans le composant, on appelle submit() en lui passant le FieldTree du formulaire et une fonction asynchrone (souvent un appel API). Si cette fonction renvoie des erreurs, on vient les appliquer automatiquement aux champs concernés :
submit() {
submit(this.talkRegistrationForm, async (form): Promise<TreeValidationResult> => {
const result = await this.#talkService.registerNewTalk(form().value());
if (result.errorCode === 'TITLE_TAKEN') {
return [
{
field: form.talkTitle,
kind: 'server',
message: 'This talk title is already taken. Please choose another one.'
}
];
}
alert('🎉 Talk successfully submitted for the SFEIR Share Lille!');
return undefined;
});
console.log('Errors after submit:', this.talkRegistrationForm().errorSummary());
}💡 Ce qui se passe
Lors de l’appel à submit() :
submitting()passe automatiquement à true → pratique pour afficher un loader ou désactiver le bouton.- Si des erreurs serveur sont retournées, elles sont converties en
ValidationErroret appliquées directement aux champs concernés (form.talkTitleici). - Le champ devient invalide (
invalid() === true) et peut afficher son message d’erreur viaerrors(). - Les erreurs sont disponibles via
form().errorSummary()également.
Lien vers la Pull Request de signal forms
Lien vers la Pull Request de fix pour empêcher le marquage des champs non interactifs (readonly, disabled, hidden) comme dirty ou touched.
Zoneless
Angular 21 amorce un tournant important pour la détection de changements en s’affranchissant progressivement de ZoneJS, jusque-là indispensable pour que les vues se mettent à jour automatiquement.
Avec cette version, la détection de changements sans zone devient le comportement par défaut. Contrairement aux versions précédentes, il n’est plus nécessaire d’importer provideZonelessChangeDetection.
Pourquoi passer en mode zoneless ?
- Meilleure performance : Angular évite des cycles de détection de changements inutiles. Désormais, la détection s’exécute uniquement quand c’est vraiment nécessaire (via des signaux, des événements DOM, des
markForCheck,ComponentRef.setInput()etc.). - Bundle plus léger
- Débogage simplifié : les stack traces sont plus claires, l’origine des changements est plus facile à comprendre.
Les nouvelles applications fonctionneront donc en mode zoneless par défaut, tout en conservant la possibilité de basculer ce comportement si nécessaire. Les projets existants peuvent, quant à eux, migrer progressivement à l’aide des outils de configuration proposés.
Vitest
Avec Angular 21, Vitest devient le framework de test par défaut, remplaçant Jasmine et Karma pour les nouveaux projets. Ce changement apporte un environnement de test moderne, rapide et intégré à l’écosystème actuel.
Avantages principaux
- Exécution très rapide grâce à Vite.
- Support natif de TypeScript et des modules ES.
- Tests possibles en environnement navigateur réel.
- API moderne, claire et riche.
Ce que cela change
- Les nouveaux projets générés via Angular CLI utilisent Vitest par défaut.
- Les tests restent exécutables avec la commande classique ng test.
- Les anciens projets peuvent continuer avec Jasmine/Karma ou migrer progressivement vers Vitest.
En résumé, cela simplifie et modernise l’expérience de test, offrant aux développeurs un framework plus performant et aligné avec les standards actuels, tout en maintenant la compatibilité avec les pratiques existantes.
FormArrayDirective
Jusqu’à présent, lorsqu’on voulait manipuler un FormArray dans un template Angular, il fallait souvent imbriquer un <form [formGroup]="..."> ou recourir à des workarounds pour que les états (touched, dirty, valid…) soient correctement propagés.
Angular 21 introduit la directive FormArrayDirective, qui permet désormais d’associer un FormArray directement à un élément <form>, exactement comme on le ferait avec un FormGroupDirective.
Autrement dit, un FormArray peut enfin devenir le “formulaire racine”.
import { FormArray, FormControl, ReactiveFormsModule } from '@angular/forms';
import { Component } from '@angular/core';
@Component({
selector: 'form-array-comp',
imports: [
ReactiveFormsModule
],
template: `
<form [formArray]="form" (ngSubmit)="event=$event">
@for (_ of controls; track $index) {
<input type="text" [formControlName]="$index">
}
</form>`
})
class FormArrayComp {
readonly controls = [
new FormControl('Hélicoptère'),
new FormControl('Bière'),
new FormControl('Pomme de terre')
];
readonly form = new FormArray(this.controls);
event!: Event;
}Lien vers la Pull Request d'ajout de FormArrayDirective
Routing — lastSuccessfulNavigation() devient un signal
Avec la version 21 de Angular, l’API du routeur continue sa transition vers les signals. L’un des changements notables est que la propriété lastSuccessfulNavigation du Router n’est plus une simple propriété, mais devient un signal et doit désormais être invoqué comme une fonction.
import { Component, computed, inject } from '@angular/core';
import { Router } from '@angular/router';
@Component({
selector: 'app-navigation-information',
template: `<p>Last visited: {{ lastSuccessfulNavigationUrl() }}</p>`
})
export class NavigationInformation {
readonly #router = inject(Router);
readonly lastSuccessfulNavigationUrl = computed(() => this.#router.lastSuccessfulNavigation()?.finalUrl);
}
Migration disponible : router-last-successful-navigation
Une migration, via la commande ng update, est fournie pour convertir automatiquement les usages existants de router.lastSuccessfulNavigation en lastSuccessfulNavigation().
Lien vers la Pull Request de migration de lastSuccessfulNavigation en signal
HTTP
Cette version apporte deux évolutions majeures autour de HttpClient :
- L'ajout d'une nouvelle propriété
responseTypedansHttpResponseetHttpErrorResponse. - La possibilité d’utiliser
HttpClientsans avoir besoin de déclarer explicitementprovideHttpClient().
1️⃣ responseType dans HttpResponse et HttpErrorResponse
Lorsque le backend Fetch est utilisé via provideHttpClient(withFetch()), les classes HttpResponse et HttpErrorResponse exposent une nouvelle propriété :
responseType?: 'basic' | 'cors' | 'default' | 'opaque' | 'opaqueredirect' | 'error';La valeur responseType correspond directement à la propriété type de l’objet Response de la Fetch API. Selon la documentation officielle :
basic— Réponse same-origin, offrant un accès complet au corps et aux en-têtes.cors— Réponse cross-origin avec CORS activé, n’exposant que les en-têtes "sûrs".opaque— Réponse cross-origin effectuée avec no-cors, rendant corps et en-têtes inaccessibles.opaqueredirect— Réponse provenant d’une redirection suivie en mode no-cors.error— Réponse représentant une erreur réseau ou un échec similaire.
📌 Important : cette propriété n’est renseignée que lorsque le backend Fetch est activé.
Lien vers la PR d'ajout de responseType
2️⃣ HttpClient fourni en root
Angular 21 simplifie également l’initialisation de HttpClient : il est désormais fourni par défaut au niveau racine de l’application, sans avoir besoin de déclarer provideHttpClient().
- Cette amélioration rend le développement plus rapide et réduit la configuration nécessaire pour les nouvelles applications.
- L’impact sur la taille du bundle est minimal (<1 kB), principalement lié à la création des providers et factories internes.
Lien vers la PR de HttpClient fourni en root
RegEx dans les templates
Avec Angular 21, il est désormais possible d’utiliser des expressions régulières directement dans les templates, ce qui simplifie la validation et le filtrage de contenu.
Cette fonctionnalité permet de réaliser certaines validations et filtrages directement dans le template, sans devoir déclarer de méthodes ou variables supplémentaires dans le TypeScript, tout en conservant la possibilité de centraliser la logique plus complexe côté composant si nécessaire.
Exemples :
1️⃣ Validation de chiffres uniquement
<input type="text" [(ngModel)]="numericValue" placeholder="Entrez des chiffres" />
<p>
Contient uniquement des chiffres:
<span [class.valid]="/^\d+$/.test(numericValue())"
[class.invalid]="!/^\d+$/.test(numericValue())">
{{ /^\d+$/.test(numericValue()) ? '✓ Oui' : '✗ Non' }}
</span>
</p>2️⃣ Validation d’email
<input type="text" [(ngModel)]="emailValue" placeholder="Entrez un email" />
<p>
Email valide:
<span [class.valid]="/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(emailValue())"
[class.invalid]="!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(emailValue())">
{{ /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(emailValue()) ? '✓ Oui' : '✗ Non' }}
</span>
</p>3️⃣ Vérification de la complexité d’un mot de passe
<input type="password" [(ngModel)]="password" placeholder="Mot de passe" />
<ul class="validation-list">
@let atLeastOneUppercase = /[A-Z]/.test(password());
<li [class.valid]="atLeastOneUppercase"
[class.invalid]="!atLeastOneUppercase">
{{ atLeastOneUppercase ? '✓' : '✗' }} Au moins une majuscule
</li>
@let atLeastOneLowercase = /[a-z]/.test(password());
<li [class.valid]="atLeastOneLowercase"
[class.invalid]="!atLeastOneLowercase">
{{ atLeastOneLowercase ? '✓' : '✗' }} Au moins une minuscule
</li>
@let atLeastOneNumber = /\d/.test(password());
<li [class.valid]="atLeastOneNumber"
[class.invalid]="!atLeastOneNumber">
{{ atLeastOneNumber ? '✓' : '✗' }} Au moins un chiffre
</li>
@let atLeastOneSpecialCharacter = /[!@#$%^&*]/.test(password());
<li [class.valid]="atLeastOneSpecialCharacter"
[class.invalid]="!atLeastOneSpecialCharacter">
{{ atLeastOneSpecialCharacter ? '✓' : '✗' }} Au moins un caractère spécial (!@#$%^&*)
</li>
@let atLeast8digits = /.{8,}/.test(password());
<li [class.valid]="atLeast8digits"
[class.invalid]="!atLeast8digits">
{{ atLeast8digits ? '✓' : '✗' }} Au moins 8 caractères
</li>
</ul>Lien vers la PR de RegEx dans les templates
Lien vers la seconde PR de RegEx dans les templates
Migrations
Cette version s’accompagne de plusieurs migrations automatiques visant à moderniser le code, simplifier les syntaxes historiques et préparer l’écosystème à des suppressions futures. Ces migrations sont proposées via ng update, et peuvent également être lancées manuellement selon les besoins.
1️⃣ ngClass → binding class
Cette migration convertit automatiquement les usages de ngClass vers la syntaxe moderne des class bindings, introduite dans les dernières versions d’Angular.
🛠️ Lancer la migration
ng generate @angular/core:ngclass-to-class📌 Exemple de migration
Avant :
<div [ngClass]="{ active: isActive, readonly: role === 'READER' }"></div>Après :
<div [class]="{ active: isActive, readonly: role === 'READER' }"></div>Angular convertit automatiquement la directive vers un binding [class] lorsque la forme est un objet simple et migrable en toute sécurité.
⚙️ Option de configuration
La migration propose une option pour gérer les cas ambigus ou complexes.
--migrate-space-separated-key
Par défaut, Angular n’effectue pas la migration lorsque les clés de l’objet ngClass contiennent plusieurs classes séparées par un espace.
Exemple non migré par défaut :
<div [ngClass]="{ 'active readonly': isActive }"></div>En activant l’option :
ng generate @angular/core:ngclass-to-class --migrate-space-separated-keyCela devient :
<div [class.active]="isActive"
[class.readonly]="isActive"></div>Lien vers la PR de migration de ngClass
2️⃣ ngStyle → binding style
Cette migration remplace automatiquement les usages de ngStyle par les style bindings modernes ([style], [style.prop]), désormais recommandés par Angular.
🛠️ Lancer la migration
ng generate @angular/core:ngstyle-to-style📌 Exemple de migration
Avant :
<div [ngStyle]="{ 'color': 'blue', 'font-weight': 'bold' }"></div>Après :
<div [style]="{ 'color': 'blue', 'font-weight': 'bold' }">📌 Exemple avec propriétés individuelles
Avant :
<div [ngStyle]="{ color: isError ? 'red' : 'green' }"></div>Après :
<div [style.color]="isError ? 'red' : 'green'"></div>⚙️ Option de configuration
--best-effort-mode
Par défaut, Angular ne migre pas les usages de ngStyle basés sur une référence d’objet, car cela peut être risqué si cet objet est muté ailleurs.
Exemple non migré par défaut :
<div [ngStyle]="styleObject"></div>En activant l’option :
ng generate @angular/core:ngstyle-to-style --best-effort-modeCela devient :
<div [style]="styleObject"></div>Lien vers la PR de migration de ngStyle
3️⃣ CommonModule → import standalone
Cette migration remplace automatiquement les imports de CommonModule par les imports standalone équivalents, provenant de @angular/common.
🛠️ Lancer la migration
ng generate @angular/core:common-to-standalone📌 Exemple de migration
Avant :
import { CommonModule } from '@angular/common';
@Component({
imports: [CommonModule],
...
})
export class MyWonderfulComponent {}Après :
import { JsonPipe, KeyValue, KeyValuePipe, LowerCasePipe } from '@angular/common';
@Component({
imports: [JsonPipe, KeyValue, KeyValuePipe, LowerCasePipe],
...
})
export class MyWonderfulComponent {}Lien vers la PR de migration de CommonModule
4️⃣ RouterTestingModule → RouterModule + provideLocationMocks()
Angular 21 retire progressivement RouterTestingModule, désormais jugé obsolète dans les nouvelles applications standalone.
Une migration automatique met à jour les suites de tests pour utiliser les APIs modernes du router, déjà compatibles avec les composants standalone. Cette migration remplace :
RouterTestingModule→RouterModule- et ajoute
provideLocationMocks()lorsque nécessaire.
🛠️ Lancer la migration
ng generate @angular/core:router-testing-module-migration📌 Exemple de migration
Avant :
import { TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { SpyLocation } from '@angular/common/testing';
const routes = [{
path: 'my-wonderful-component',
component: MyWonderfulComponent
}];
describe('SpyLocation with use urlChanges', () => {
let spy: SpyLocation;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [
HttpClientTestingModule,
RouterTestingModule.withRoutes(routes)
]
});
spy = TestBed.inject(SpyLocation);
});
it('dummy', () => expect(spy.urlChanges).toBeDefined());
});Après :
import { TestBed } from '@angular/core/testing';
import { RouterModule } from '@angular/router';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { SpyLocation, provideLocationMocks } from '@angular/common/testing';
const routes = [{
path: 'my-wonderful-component',
component: MyWonderfulComponent
}];
describe('SpyLocation with use urlChanges', () => {
let spy: SpyLocation;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [
HttpClientTestingModule,
RouterModule.forRoot(routes)
],
providers: [provideLocationMocks()]
});
spy = TestBed.inject(SpyLocation);
});
it('dummy', () => expect(spy.urlChanges).toBeDefined());
});📌 Important : la migration ajoute provideLocationMocks() uniquement lorsque des usages de Location ou LocationStrategy sont détectés et qu’aucun mock personnalisé n’est présent.
Lien vers la PR de migration de RouterTestingModule
KeyValue : support des clés facultatives
Le pipe KeyValue gagne en flexibilité et accepte désormais les objets contenant des propriétés optionnelles ou nulles, ce qui évite des erreurs de typage et des contournements inutiles dans les templates.
Cette évolution répond à une limitation connue (issue #46867) où le pipe ne supportait pas correctement les interfaces avec des clés optionnelles, ou celles pouvant contenir undefined.
Exemple avec interface partiellement définie (Partial<UserProfile>) :
interface UserProfile {
firstName?: string;
lastName?: string;
email?: string;
phone?: string;
}
@Component({
standalone: true,
imports: [KeyValuePipe],
template: `
<pre>{{ user() | json }}</pre>
<div class="kv-item" [class.undefined-value]="item.value === undefined">
@for (item of user() | keyvalue; track item.key) {
<div>{{ item.key }} → {{ item.value }}</div>
}
@empty {
<p class="empty">Aucune propriété définie</p>
}
</div>
`
})
export class ExampleOptionalKeys {
user = signal<Partial<UserProfile>>({
firstName: 'Alice',
email: 'alice@example.com'
});
}Aperçu du rendu

Résultat
- Le pipe affiche uniquement les clés réellement présentes dans l’objet.
- Les propriétés manquantes ou optionnelles non définies ne provoquent plus d’erreurs.
- Le pipe reste pleinement compatible avec un comparateur personnalisé via
keyvalue: comparator.
Lien vers la PR des clés optionnelles pour le pipe KeyValue
Conclusion
Angular 21 marque une étape décisive dans la modernisation du framework : des Signal Forms plus claires et plus puissantes, un mode zoneless désormais prêt pour la production, un routeur et un HttpClient simplifiés, ainsi que l’arrivée de Vitest comme nouveau standard de test.
Cette version accélère Angular, réduit le code nécessaire et rapproche encore davantage le framework du modèle réactif centré sur les signals.
Avec les migrations automatiques, la transition reste progressive et maîtrisée, permettant aux projets existants de bénéficier sereinement de ces évolutions.
Et au-delà de cette release, Angular continue de tracer une direction très nette : davantage de performances, moins de complexité, et un écosystème centré sur les standards modernes du web.
La suite promet d’être passionnante !
