Aller au contenu

Angular 21 expliqué : un guide complet pour adopter Signal Forms et le mode zoneless

Angular 21 est arrivé et apporte de nombreuses nouveautés ainsi que des migrations automatiques pour simplifier le développement, renforcer la réactivité et moderniser les pratiques. Ce guide détaille Signal Forms, HttpClient, router, Vitest (tests), le mode zoneless et plus encore.

Tout connaître sur Angular 21

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.

Angular Signals vs RxJS : Une Transition vers des Services Plus Performants
Les Signaux Angular révolutionnent la réactivité des applications en surpassant RxJS dans la gestion du changement. Découvrez comment migrer vos services pour des performances optimales, illustré avec un exemple concret.

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

form().value()

Valeur du champ.

form().disabled()

Indique si le champ est non modifiable.

form().errors()

Liste des erreurs.

form().valid() / form().invalid()

Indique si la valeur du champ est valide ou invalide.

form().dirty()

Indique si la valeur du champ a été modifiée par l'utilisateur.

form().touched()

Indique si le champ a été touché par l'utilisateur.

form().submitting()

Indique si submit(...) est en cours.

form().hidden()

Indique si le champ est visible.

form().disabledReasons()

Donne les raisons expliquant pourquoi les champs sont désactivés.

form().pending()

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

REQUIRED

Indique si le champ est requis.

MIN

Représente la valeur minimale autorisée pour un champ numérique.

MAX

Représente la valeur maximale autorisée pour un champ numérique.

MIN_LENGTH

Définit la longueur minimale d’un texte ou d’un tableau.

MAX_LENGTH

Définit la longueur maximale d’un texte ou d’un tableau.

PATTERN

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 ValidationError et appliquées directement aux champs concernés (form.talkTitle ici).
  • Le champ devient invalide (invalid() === true) et peut afficher son message d’erreur via errors().
  • Les erreurs sont disponibles via form().errorSummary() également.
feat(forms): add experimental signal-based forms by mmalerba · Pull Request #63408 · angular/angular
This commit introduces an experimental version of a new signal-based forms API for Angular. This new API aims to explore how signals can be leveraged to create a more declarative, intuitive, and re…

Lien vers la Pull Request de signal forms

feat(forms): Prevents marking fields as touched/dirty when state is hidden/r… by SkyZeroZx · Pull Request #63633 · angular/angular
feat(forms): prevent marking non-interactive fields as touched or dirty Summary This PR addresses the TODO comments in FieldNodeState class by implementing logic to prevent fields that are readonly…

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.

fix(core): Remove Zone-based change provider from internals by default by atscott · Pull Request #63382 · angular/angular
This change removes the internally provided ZoneJS-based change detection scheduler. This makes Angular Zoneless by default and allows tree-shaking of the Zone change detection providers. BREAKING…
feat(core): Add migration for zoneless by default. by JeanMeche · Pull Request #63042 · angular/angular
This migration moves the zone configuration from bootstrapModule/bootstrapApplication to providers. This also include a migration for TestBed.initTestEnvironment WIP

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;
}
feat(forms): Add `FormArrayDirective` by JeanMeche · Pull Request #55880 · angular/angular
The FormArrayDirective will allow to have a FormArray as a top-level form object. NgControlStatusGroup directive will be applied to the FormArrayDirective NgForm will still create a FormGroup Usa…

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().

feat(router): convert `lastSuccessfulNavigation` to signal by JeanMeche · Pull Request #63057 · angular/angular
This commit also include an ng update migration to ensure lastSuccessfulNavigation is invoked. BREAKING CHANGE: lastSuccessfulNavigation is now a signal and needs to be invoked

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é responseType dans HttpResponse et HttpErrorResponse.
  • La possibilité d’utiliser HttpClient sans avoir besoin de déclarer explicitement provideHttpClient().

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é.

feat(http): Add reponseType property to HttpResponse and HttpErrorRes… by SkyZeroZx · Pull Request #63043 · angular/angular
HttpResponse responseType Property Support This commit adds support for the Fetch API&#39;s responseType property in HttpResponse and HttpErrorResponse when using HttpClient with the withFetch prov…

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.
feat(http): Provide http services in root by JeanMeche · Pull Request #56212 · angular/angular
The changes introduced in this commit allows to use the HttpClient without the provider function. Bundle size impact &lt;1kB, mostly due to creating the providers and factories for the services pro…

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>
feat(core): support regular expressions in templates by crisbeto · Pull Request #63857 · angular/angular
Updates the template syntax to support inline regular expressions. Also includes some logic to reuse regular expressions between call sites, unless they’re marked as global.

Lien vers la PR de RegEx dans les templates

feat(core): support regular expressions in templates by crisbeto · Pull Request #63887 · angular/angular
This is a second attempt at #63857 with some fixes to avoid the breakages. Updates the template syntax to support inline regular expressions. Also includes some logic to reuse regular expressions b…

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-key

Cela devient :

<div [class.active]="isActive"
     [class.readonly]="isActive"></div>
feat(migrations): ngclass directives to class bindings by aparzi · Pull Request #62983 · angular/angular
PR Checklist Please check if your PR fulfills the following requirements: The commit message follows our guidelines: https://github.com/angular/angular/blob/main/contributing-docs/commit-message-…

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-mode

Cela devient :

<div [style]="styleObject"></div>
feat(migrations): add migration to convert ngStyle to use style by aparzi · Pull Request #63517 · angular/angular
Add migration to convert ngStyle to use style PR Checklist Please check if your PR fulfills the following requirements: The commit message follows our guidelines: https://github.com/angular/angul…

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 {}
feat(migrations): Adds support for CommonModule to standalone migration by SkyZeroZx · Pull Request #64138 · angular/angular
Common to Standalone Migration This migration replaces CommonModule imports with specific standalone imports from @angular/common. Usage ng g @angular/core:common-to-standalone --path src/app/featu…

Lien vers la PR de migration de CommonModule

4️⃣ RouterTestingModuleRouterModule + 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 :

  • RouterTestingModuleRouterModule
  • 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.

feat(migrations): Adds migration for deprecated router testing module by SkyZeroZx · Pull Request #64217 · angular/angular
Adds a schematics migration to convert RouterTestingModule usages in test suites to RouterModule, and to add provideLocationMocks() when Location or LocationStrategy is imported and no custom provi…

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

L’image illustre comment le KeyValue pipe gère les propriétés définies et celles non définies.

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.
feat(common): Support of optional keys for the KeyValue pipe by JeanMeche · Pull Request #48814 · angular/angular
This commit is extending the capabilities of the KeyValue pipe by allowing interfaces with optional keys. fixes #46867 PR Type What kind of change does this PR introduce? Feature Does this PR in…

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 !

Dernier