~4 min de lecture
Les templates Angular ont changé plus vite que tes habitudes — v17 à v21.2
Si tu développes avec Angular depuis quelques années, tu as probablement encore des réflexes d'une époque révolue : des méthodes wrapper dans le TS pour un simple update() sur un signal, des concaténations de strings à rallonge dans le HTML, des *ngIf partout.
La réalité : le moteur de template d'Angular a reçu plus d'améliorations en deux ans qu'en cinq ans auparavant. Angular converge délibérément vers une syntaxe template qui s'aligne avec les expressions TypeScript — et ça va continuer.
Voici tout ce qui a changé, version par version, avec ce que ça implique concrètement pour ton code.
Angular 17 — Le control flow qui remplace tout
La refonte la plus visible : fini *ngIf, *ngFor, *ngSwitch. Le nouveau control flow est une syntaxe native du compilateur, plus performante et bien plus lisible.
@if / @else if / @else
<!-- Avant -->
<div *ngIf="user(); else loading">{{ user().name }}</div>
<ng-template #loading><spinner /></ng-template>
<!-- Après -->
@if (user()) {
<p>{{ user().name }}</p>
} @else if (isPending()) {
<spinner />
} @else {
<p>Non connecté</p>
}
Le gain principal : plus de ng-template pour les branches else. La lisibilité est sans comparaison.
@for avec track obligatoire
<!-- Avant -->
<li *ngFor="let item of items(); trackBy: trackById">
{{ item.name }}
</li>
<!-- Après -->
@for (item of items(); track item.id) {
<li>{{ $index + 1 }}. {{ item.name }}</li>
} @empty {
<p>Aucun résultat</p>
}
Variables automatiquement disponibles dans la boucle : $index, $count, $first, $last, $odd, $even. Le bloc @empty remplace l'ancien pattern *ngIf="items.length === 0" en parallèle du *ngFor.
@switch avec @default
@switch (status()) {
@case ('active') { <badge-active /> }
@case ('inactive') { <badge-inactive /> }
@default { <badge-unknown /> }
}
@defer — le lazy loading déclaratif
C'est la feature la plus sous-estimée de v17. Tu peux déférer le chargement d'un bloc de template avec des conditions précises, sans toucher au routing.
@defer (on viewport; prefetch on idle) {
<heavy-chart [data]="chartData()" />
} @loading (minimum 300ms) {
<skeleton-chart />
} @placeholder {
<div class="chart-placeholder"></div>
} @error {
<p>Impossible de charger le graphique</p>
}
Les triggers disponibles : on idle, on viewport, on interaction, on hover, on immediate, on timer(500ms). Combinables avec prefetch on pour anticiper le chargement.
Angular 18-19 — @let : les variables locales dans le template
Fini les as dans les *ngIf pour extraire une valeur. @let déclare une variable locale au scope du bloc dans lequel elle est définie.
@let user = currentUser();
@let displayName = user.firstName + ' ' + user.lastName;
<h1>Bonjour {{ displayName }}</h1>
<p>{{ user.email }}</p>
Le cas d'usage le plus puissant : le narrowing de signal.
@let profile = userProfile();
@if (profile) {
@let stats = profile.stats; <!-- type narrowé, plus de null check -->
<div>Articles publiés : {{ stats.publishedCount }}</div>
<div>Vues totales : {{ stats.totalViews }}</div>
}
Sans @let, tu aurais soit une méthode dans le TS, soit profile()!.stats avec un non-null assertion risqué. Désormais stable depuis Angular 19.
Angular 19.2 — Template literals
Concaténer des strings dans un template Angular, c'était verbeux. Plus maintenant.
<!-- Avant -->
{{ 'Bonjour ' + firstName() + ' ' + lastName() + ' !' }}
<img [src]="'/avatars/' + userId() + '.webp'" />
<!-- Après Angular 19.2 -->
{{ `Bonjour ${firstName()} ${lastName()} !` }}
<img [src]="`/avatars/${userId()}.webp`" />
Combiné avec @let, ça donne des templates très propres :
@let user = currentUser();
@let avatarUrl = `/avatars/${user.id}.webp`;
@let greeting = `Bonjour ${user.firstName}, tu as ${user.unreadCount} message(s)`;
<img [src]="avatarUrl" [alt]="user.firstName" />
<p>{{ greeting }}</p>
Angular 20 — Opérateurs supplémentaires + tagged template literals
Angular 20 continue l'alignement avec les expressions TypeScript en ajoutant plusieurs opérateurs manquants.
Exponentiation (**)
<div [style.font-size.px]="baseFontSize() ** scaleFactor()">
{{ 2 ** gridLevel() }} colonnes
</div>
Opérateur in (narrowing de type)
Vérifier la présence d'une propriété dans un objet union, directement dans le template :
@for (event of timeline(); track event.id) {
@let hasLocation = 'location' in event;
@if (hasLocation) {
<map-pin [coords]="event.location" />
}
<event-card [event]="event" [showLocation]="hasLocation" />
}
C'est différent de vérifier si la valeur est null : in teste l'existence de la clé elle-même.
Opérateur void
Utile quand ton handler retourne false (ce qui déclencherait un preventDefault() non intentionnel) :
<!-- Sans void : le false retourné déclenche preventDefault() -->
<button (click)="logger.track('cta-click')">CTA</button>
<!-- Avec void : on ignore explicitement la valeur de retour -->
<button (click)="void logger.track('cta-click')">CTA</button>
Tagged template literals
Pour les cas où tu as besoin de formatter une string via une fonction :
{{ highlight`Résultats pour ${searchTerm()} dans ${category()}` }}
protected highlight(strings: TemplateStringsArray, ...values: string[]) {
return strings.reduce((acc, str, i) =>
acc + str + (values[i] ? `<mark>${values[i]}</mark>` : ''), '');
}
Angular 21.1 — Spread operator + @case fall-through
Spread operator dans les templates
Tu n'as plus besoin de computed() ou de méthodes helper pour composer des tableaux et objets dans la vue. La logique d'affichage reste dans le template.
<!-- Tableau : fusion de sources -->
@let allNotifications = [...systemAlerts(), ...userNotifications()];
@for (notif of allNotifications; track notif.id) {
<notification-item [notif]="notif" />
}
<!-- Objet : extension de config de base -->
<action-button
[config]="{ ...defaultButtonConfig(), disabled: isSubmitting(), label: submitLabel() }"
/>
Avant, ce genre de composition vivait dans le TS comme signal computed(). Ce n'était pas faux, mais quand c'est purement de la logique d'affichage, le template est le bon endroit.
@case fall-through
<!-- Avant : duplication inévitable -->
@switch (status()) {
@case ('pending') { <spinner /> }
@case ('processing') { <spinner /> }
@case ('done') { <check-icon /> }
}
<!-- Après Angular 21.1 -->
@switch (status()) {
@case ('pending')
@case ('processing') { <spinner /> }
@case ('done') { <check-icon /> }
}
Angular 21.2 — Arrow functions dans les templates
La fin des wrapper methods. Si tu as une classe remplie de méthodes d'une ligne qui font juste signal.update(...), tu peux les supprimer.
<!-- Avant : tu avais ces méthodes dans le TS -->
<!-- protected increment() { this.count.update(n => n + 1); } -->
<!-- protected decrement() { this.count.update(n => n > 0 ? n - 1 : 0); } -->
<!-- Après Angular 21.2 -->
<button (click)="count.update(n => n + 1)">+</button>
<button (click)="count.update(n => n > 0 ? n - 1 : 0)">-</button>
<button (click)="items.update(list => [...list, newItem()])">Ajouter</button>
Les règles à connaître
1. Return implicite uniquement — pas de block body
<!-- ❌ Block body interdit -->
<button (click)="count.update(n => { return n + 1; })">+</button>
<!-- ✅ Expression uniquement -->
<button (click)="count.update(n => n + 1)">+</button>
2. Object literal → parenthèses obligatoires
<!-- ❌ Angular interprète {} comme un block body -->
<button (click)="settings.update(s => { theme: 'dark' })">❌</button>
<!-- ✅ Parenthèses pour l'objet littéral -->
<button (click)="settings.update(s => ({ ...s, theme: 'dark' }))">✅</button>
3. Pas de pipes dans le body de l'arrow
Les pipes sont de la syntaxe Angular, pas du JavaScript. Tu peux les appliquer sur le résultat de l'arrow, pas à l'intérieur.
<!-- ❌ -->
<button (click)="label.set(value() | uppercase)">❌</button>
<!-- ✅ Appliquer le pipe sur le résultat -->
{{ (count() | number:'1.0-2') }}
4. Arrow function comme listener direct : non
<!-- ❌ Erreur de compilation -->
<button (click)="() => doSomething()">❌</button>
<!-- ✅ Appel direct ou arrow dans un callback -->
<button (click)="doSomething()">✅</button>
<button (click)="items.update(list => [...list, item()])">✅</button>
instanceof maintenant supporté
@if (error() instanceof HttpErrorResponse) {
<p>Erreur réseau : {{ error().status }}</p>
} @else if (error() instanceof ValidationError) {
<p>Données invalides</p>
}
@switch exhaustif
Le compilateur vérifie maintenant que tous les cas possibles d'un type union sont couverts dans un @switch. Si tu oublies un cas, tu as une erreur à la compilation — pas au runtime.
Récapitulatif
| Feature | Version | Ce que ça remplace |
|---|---|---|
@if, @for, @switch, @defer |
17 | *ngIf, *ngFor, *ngSwitchCase, lazy routing |
@let |
18→19 | as dans *ngIf, méthodes getter de template |
| Template literals (untagged) | 19.2 | Concaténation avec + |
**, in, void, tagged literals |
20 | Méthodes helper pour opérations simples |
Spread ..., @case fall-through |
21.1 | computed() purement pour la vue, cases dupliquées |
Arrow functions, instanceof, @switch exhaustif |
21.2 | Wrapper methods pour signal.update() |
Ce que ça change vraiment
Le template Angular n'est plus un langage à part où tu dois revenir au TS dès que tu veux faire quelque chose de non trivial. L'objectif affiché de l'équipe Angular est clair : que toute expression TypeScript valide soit valide dans un template.
On n'y est pas encore — le spread sur les arguments de fonction, les destructuring assignments, les générateurs... tout ça n'est pas supporté. Mais la progression est constante et la direction est bonne.
Si ton projet est encore sur Angular 17-19, les migrations sont non-breaking. Le schematic @angular/core:control-flow migre automatiquement l'ancien syntax. Les autres features s'adoptent progressivement, au fur et à mesure que tu touches les fichiers.