Découvrez le nouveau control flow d'Angular 17 : remplacez *ngIf, *ngFor et ngSwitch par la syntaxe native @if, @for et @switch pour des templates plus.
Pourquoi Angular a remplacé les directives
Les directives structurelles *ngIf, *ngFor et [ngSwitch] fonctionnaient mais avaient des limitations importantes. D'abord, elles nécessitaient d'être importées dans chaque composant standalone (imports: [CommonModule] ou les imports individuels). Ensuite, leur syntaxe avec les macros * était déroutante pour les nouveaux développeurs habitués à JavaScript.
Le nouveau control flow est intégré directement dans le compilateur Ivy d'Angular. Pas d'import nécessaire, syntaxe proche de JavaScript, et TypeScript peut narrow les types dans les blocs @if.
| Ancienne syntaxe | Nouvelle syntaxe | Avantage principal |
|---|---|---|
*ngIf | @if / @else if / @else | Pas de ng-template, @else if natif |
*ngFor + trackBy | @for + track | track obligatoire (meilleure perf), @empty intégré |
[ngSwitch] | @switch / @case / @default | Syntaxe propre, plus de surcharge DOM |
| N/A | @let | Variables locales dans les templates |
NgIf, NgFor, CommonModule (import requis) | Aucun import nécessaire | Moins de boilerplate dans les imports |
@if — conditions et alias as
Le bloc @if évalue une expression. Si truthy, le contenu est rendu. L'alias as capture la valeur de l'expression dans une variable locale — particulièrement utile avec les getters calculés ou les appels de méthode pour éviter de les appeler plusieurs fois.
<!-- @if simple -->
@if (user) {
<h2>Bonjour, {{ user.name }}</h2>
}
<!-- @if avec @else if et @else chaînés -->
@if (loadingState() === 'loading') {
<div class="spinner-border text-primary"></div>
} @else if (loadingState() === 'error') {
<div class="alert alert-danger">{{ errorMessage() }}</div>
} @else if (items().length === 0) {
<p class="text-muted">Aucun résultat trouvé.</p>
} @else {
<!-- Rendu de la liste -->
<app-item-list [items]="items()" />
}
<!-- Alias "as" — capture la valeur pour éviter les appels répétés -->
<!-- Sans alias : user() appelé 3 fois -->
@if (user()) {
<span>{{ user()!.name }} ({{ user()!.email }})</span>
}
<!-- Avec alias "as" : user() appelé une seule fois, la valeur est capturée -->
@if (user(); as u) {
<span>{{ u.name }} ({{ u.email }})</span>
<img [src]="u.avatarUrl" [alt]="u.name">
}
as garantit que la variable est non-null dans le bloc, mais TypeScript peut quand même signaler un warning selon la configuration strictTemplates.
@if avec observables et Signals
Avec le pipe async et les Signals, @if s'adapte aux deux modèles de réactivité d'Angular.
<!-- Avec le pipe async (RxJS Observable) -->
<!-- user$ est un Observable<User | null> -->
@if (user$ | async; as user) {
<!-- "user" est la valeur déroulée, non-null ici -->
<app-user-profile [user]="user" />
<!-- Avantage : async pipe gère automatiquement la désinscription -->
}
<!-- Avec les Signals Angular (recommandé) -->
@if (currentUser()) {
<!-- currentUser() est un Signal<User | null> -->
<app-user-profile [user]="currentUser()!" />
}
<!-- Pattern avec resourceRef (Angular 19+) -->
@if (usersResource.status() === 'resolved') {
@for (user of usersResource.value()!; track user.id) {
<app-user-card [user]="user" />
}
} @else if (usersResource.status() === 'error') {
<app-error-state [error]="usersResource.error()" />
} @else {
<app-loading-skeleton />
}
<!-- Combiner async et conditions multiples -->
@if ((products$ | async) ?? [] as products) {
@if (products.length > 0) {
<app-product-grid [products]="products" />
} @else {
<p>Catalogue en cours de chargement...</p>
}
}
@for — itération et clause @empty
@for itère sur n'importe quel itérable : tableau, Set, Map, ou tout objet implémentant le protocole itérateur JavaScript. La clause @empty est rendue quand l'itérable est vide ou null — cela évite de devoir combiner @if (items.length === 0) séparé.
<!-- Tableau simple avec @empty -->
<ul class="list-group">
@for (task of tasks(); track task.id) {
<li class="list-group-item d-flex justify-content-between">
<span [class.text-decoration-line-through]="task.done">{{ task.title }}</span>
<button (click)="toggleTask(task.id)" class="btn btn-sm btn-outline-primary">
{{ task.done ? 'Annuler' : 'Terminer' }}
</button>
</li>
} @empty {
<li class="list-group-item text-muted text-center py-4">
Aucune tâche — ajoutez-en une ci-dessus.
</li>
}
</ul>
<!-- Itérer sur un Map -->
<!-- categoryMap: Map<string, Product[]> -->
@for (entry of categoryMap; track entry[0]) {
<section>
<h3>{{ entry[0] }}</h3> <!-- clé -->
@for (product of entry[1]; track product.id) {
<app-product-card [product]="product" />
}
</section>
}
<!-- @for imbriqués — tableau de tableaux -->
@for (category of catalog(); track category.id) {
<div class="category-section">
<h2>{{ category.name }}</h2>
@for (product of category.products; track product.sku) {
<app-product-row [product]="product" />
} @empty {
<p class="text-muted">Aucun produit dans cette catégorie.</p>
}
</div>
} @empty {
<div class="alert alert-info">Le catalogue est vide.</div>
}
track — performance de reconciliation
La clause track est obligatoire dans Angular 17+. Elle indique à Angular comment identifier un élément d'une itération à l'autre lors des rerenders. Sans track, Angular détruirait et recréerait tous les composants à chaque changement de la liste — catastrophique pour les performances et pour les états locaux des composants.
<!-- ❌ À éviter : track $index -->
<!-- Si un élément est inséré au début, tous les index changent -->
<!-- Angular recrée tous les composants même si les données n'ont pas changé -->
@for (item of items; track $index) {
<app-item [data]="item" />
}
<!-- ✅ Recommandé : track par identifiant unique -->
@for (item of items; track item.id) {
<app-item [data]="item" />
}
<!-- ✅ Avec expression : track par propriété composée -->
@for (order of orders; track order.userId + '_' + order.orderId) {
<app-order [order]="order" />
}
<!-- ✅ Pour les objets primitifs (strings, numbers) sans ID -->
@for (tag of tags; track tag) {
<span class="badge bg-primary">{{ tag }}</span>
}
<!-- 💡 Pourquoi track $index est un anti-pattern -->
<!-- Liste initiale : [A, B, C] -->
<!-- Insertion en début : [X, A, B, C] -->
<!-- Avec track $index: Angular voit index 0 = X (nouveau), 1 = A (mise à jour depuis A), etc. -->
<!-- → 4 mises à jour DOM au lieu de 1 insertion -->
<!-- Avec track item.id: Angular voit que A/B/C ont le même ID → déplace, insère X uniquement -->
<!-- → 1 seule insertion DOM -->
Variables implicites $index, $first...
Dans chaque bloc @for, 6 variables implicites sont disponibles et se nomment avec le préfixe $. Elles doivent être capturées dans une variable locale via la clause let.
<!-- Toutes les variables implicites disponibles -->
@for (product of products(); track product.id;
let idx = $index,
first = $first,
last = $last,
even = $even,
odd = $odd,
count = $count) {
<div class="product-row"
[class.first-row]="first"
[class.last-row]="last"
[class.row-alternate]="even">
<!-- Numérotation 1-based -->
<span class="row-number">{{ idx + 1 }}/{{ count }}</span>
<span>{{ product.name }}</span>
@if (!last) {
<hr> <!-- Séparateur sauf après le dernier élément -->
}
</div>
}
<!-- Cas d'usage : pagination visuelle avec $first/$last -->
<nav aria-label="Pagination">
<ul class="pagination">
@for (page of pages(); track page; let first = $first, last = $last) {
<li class="page-item" [class.active]="page === currentPage()">
<a class="page-link"
[class.rounded-start]="first"
[class.rounded-end]="last"
(click)="goToPage(page)">{{ page }}</a>
</li>
}
</ul>
</nav>
@switch — pattern matching dans les templates
@switch utilise l'égalité stricte (===) pour comparer la valeur de l'expression avec chaque @case. Contrairement à @if / @else if, il ne supporte que les comparaisons d'égalité, mais il est plus lisible quand on a 3+ cas exclusifs.
<!-- Pattern avec discriminated union (état d'une commande) -->
<!-- orderStatus: Signal<'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled'> -->
@switch (orderStatus()) {
@case ('pending') {
<span class="badge bg-warning text-dark">En attente de paiement</span>
}
@case ('processing') {
<span class="badge bg-info">En préparation</span>
}
@case ('shipped') {
<span class="badge bg-primary">Expédié</span>
<a [href]="trackingUrl()" target="_blank">Suivre la livraison</a>
}
@case ('delivered') {
<span class="badge bg-success">Livré</span>
<button (click)="leaveReview()">Laisser un avis</button>
}
@case ('cancelled') {
<span class="badge bg-danger">Annulé</span>
}
@default {
<span class="badge bg-secondary">Statut inconnu</span>
}
}
<!-- Rendu de composant différent selon le rôle utilisateur -->
@switch (userRole()) {
@case ('admin') {
<app-admin-dashboard />
}
@case ('manager') {
<app-manager-view [teamId]="teamId()" />
}
@case ('viewer') {
<app-readonly-dashboard />
}
@default {
<app-login-prompt />
}
}
@let — variables locales dans les templates
@let (Angular 18+) permet de déclarer des variables locales dans un template pour éviter les appels répétés à des expressions coûteuses ou améliorer la lisibilité des templates complexes.
<!-- Sans @let : l'expression est évaluée plusieurs fois -->
<div>
<h2>{{ order().customer.fullName }}</h2>
<p>Email : {{ order().customer.email }}</p>
<p>Adresse : {{ order().customer.address.line1 }}, {{ order().customer.address.city }}</p>
</div>
<!-- Avec @let : l'expression est évaluée une seule fois et réutilisée -->
@let o = order();
@let customer = o.customer;
@let address = customer.address;
<div>
<h2>{{ customer.fullName }}</h2>
<p>Email : {{ customer.email }}</p>
<p>Adresse : {{ address.line1 }}, {{ address.city }}</p>
</div>
<!-- @let avec calcul conditionnel -->
@let discountedPrice = product().price * (1 - (discount() / 100));
@let savings = product().price - discountedPrice;
<div class="price-block">
<span class="original-price text-decoration-line-through">{{ product().price | currency:'EUR' }}</span>
<span class="discounted-price text-success fw-bold">{{ discountedPrice | currency:'EUR' }}</span>
<span class="badge bg-danger">Économisez {{ savings | currency:'EUR' }}</span>
</div>
Migration automatique et cas limites
Angular fournit un schematic de migration qui gère 95% des cas automatiquement.
# Migration automatique vers le nouveau control flow
ng generate @angular/core:control-flow
# Options de migration :
# --path ./src/app/specific-module → migrer seulement un sous-dossier
# --format true → formatter avec Prettier après migration
Certains cas nécessitent une adaptation manuelle :
<!-- Cas 1 : *ngIf avec "as" dans des structures imbriquées -->
<!-- Avant -->
<div *ngIf="getUser() | async as user">
<div *ngIf="getPermissions(user) | async as perms">
Rôle : {{ user.role }}, Permissions : {{ perms.length }}
</div>
</div>
<!-- Après -->
@if (getUser() | async; as user) {
@if (getPermissions(user) | async; as perms) {
Rôle : {{ user.role }}, Permissions : {{ perms.length }}
}
}
<!-- Cas 2 : *ngFor avec index dans les bindings de classe -->
<!-- Avant -->
<li *ngFor="let item of items; let i = index; let isLast = last"
[class.last]="isLast">
<!-- Après -->
@for (item of items; track item.id; let i = $index, isLast = $last) {
<li [class.last]="isLast">
}
<!-- Cas 3 : Conteneur *ngFor sans balise parent -->
<!-- Avant : ng-container permettait d'itérer sans élément DOM -->
<ng-container *ngFor="let item of items">
<dt>{{ item.key }}</dt>
<dd>{{ item.value }}</dd>
</ng-container>
<!-- Après : @for gère nativement les fragments multi-éléments -->
@for (item of items; track item.key) {
<dt>{{ item.key }}</dt>
<dd>{{ item.value }}</dd>
}
Pièges et patterns à éviter
Appeler des méthodes dans @if/@for
<!-- ❌ Mauvais : getFilteredUsers() est appelé à chaque détection de changements -->
@for (user of getFilteredUsers(); track user.id) {
<app-user [user]="user" />
}
<!-- ✅ Bon : utiliser un Signal computed pour mémoriser le résultat -->
// Dans le composant TypeScript :
filteredUsers = computed(() =>
this.users().filter(u => u.role === this.selectedRole())
);
// Dans le template :
@for (user of filteredUsers(); track user.id) {
<app-user [user]="user" />
}
Oublier @empty sur les listes contrôlées par l'utilisateur
<!-- ❌ Mauvais : rien ne s'affiche si la recherche ne retourne pas de résultats -->
@for (result of searchResults(); track result.id) {
<app-result [data]="result" />
}
<!-- ✅ Bon : toujours prévoir le cas vide pour une meilleure UX -->
@for (result of searchResults(); track result.id) {
<app-result [data]="result" />
} @empty {
<div class="alert alert-light text-center">
Aucun résultat pour "{{ searchQuery() }}".
<button (click)="clearSearch()" class="btn btn-link p-0">Effacer la recherche</button>
</div>
}
@for ont une expression track pertinente (jamais $index pour des listes dynamiques) · Remplacer les getters/méthodes coûteuses dans les boucles par des computed() · Utiliser @let pour les expressions évaluées plusieurs fois · Toujours prévoir @empty pour les listes liées à des recherches ou filtres utilisateur.