Front-end angularforall.com

- Angular 17 : @if, @for, @switch – le nouveau control flow

Angular Angular 17 Control Flow Template
Angular 17 : @if, @for, @switch – le nouveau control flow

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 syntaxeNouvelle syntaxeAvantage principal
*ngIf@if / @else if / @elsePas de ng-template, @else if natif
*ngFor + trackBy@for + tracktrack obligatoire (meilleure perf), @empty intégré
[ngSwitch]@switch / @case / @defaultSyntaxe propre, plus de surcharge DOM
N/A@letVariables locales dans les templates
NgIf, NgFor, CommonModule (import requis)Aucun import nécessaireMoins 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">
}
TypeScript narrow dans @if : Angular ne narrow pas automatiquement les types dans les templates comme TypeScript le fait dans le code TypeScript. L'alias 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>
}
Checklist migration : Vérifier que tous les @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.

Partager