Nouveau control flow — @if, @for, @switch, @defer
TL;DR — Depuis Angular 17, le langage de template a un control flow natif intégré au compilateur :
@if,@for,@switchremplacent*ngIf,*ngFor,*ngSwitch, et@deferintroduit le lazy rendering par bloc avec déclencheurs déclaratifs. Ce n'est pas juste du sucre syntaxique : le compilateur parse ces blocs au niveau AST, ce qui donne un meilleur tree-shaking (plus besoin d'importerCommonModule), un message d'erreur typé, un support natif des branches@else, et un binding fin avec les signals. Letrackdevient obligatoire dans@for, ce qui force à réfléchir à l'identité des items et évite la classe des bugs "tout se re-rend".@deferest un game-changer pour le LCP : on découpe l'UI en lazy chunks déclenchés par viewport, interaction, idle, etc. Une migration automatique existe :ng generate @angular/core:control-flow.
🧠 Mental model — ASCII + analogie
Le control flow Angular avant 17 était implémenté par des directives structurelles (*ngIf → NgIf, *ngFor → NgForOf). Côté compilo, ce sont des composants invisibles qui manipulent un TemplateRef. Conséquence : il fallait les importer, le typage des let- était limité, et chaque pattern un peu avancé (@empty, @else if) demandait une gymnastique syntaxique (*ngIf="x; else otherTpl").
Le nouveau control flow est un mot-clé du compilateur. Conceptuellement, c'est plus proche du {#if} de Svelte ou du v-if de Vue : le compilateur sait que c'est une branche, et émet directement des instructions de rendu.
AVANT (Angular ≤ 16)
┌───────────────────────────────────────┐
│ <div *ngIf="user; else loading"> │
│ {{ user.name }} │
│ </div> │
│ <ng-template #loading>…</ng-template> │
│ │
│ ↳ NgIf est une directive importée │
│ ↳ ng-template séparé, scopes liés │
└───────────────────────────────────────┘
MAINTENANT (Angular 17+)
┌───────────────────────────────────────┐
│ @if (user(); as u) { │
│ <div>{{ u.name }}</div> │
│ } @else { │
│ loading… │
│ } │
│ │
│ ↳ syntaxe native du compilo │
│ ↳ pas d'import, narrowing TS │
│ ↳ branche else inline │
└───────────────────────────────────────┘L'analogie complète : c'est exactement la transition que JSX a faite quand React a intégré JSX dans son tooling officiel, plutôt que de continuer à createElement('div', null, …). C'est du first-class language.
@defer est un cas à part : c'est l'équivalent de React.lazy + Suspense + IntersectionObserver + requestIdleCallback, unifié par une seule syntaxe déclarative.
@defer (on viewport; prefetch on idle) {
<heavy-chart />
} @placeholder { <chart-skeleton /> }
@loading (after 100ms) { Loading… }
@error { Échec }
│
▼
┌──────────────────────────────────────────────┐
│ 1. Au load : on rend <chart-skeleton/> │
│ 2. Quand idle : on **prefetch** le chunk JS │
│ 3. Quand le bloc entre dans le viewport : │
│ on injecte <heavy-chart/> │
│ 4. Si la charge prend > 100ms : "Loading…" │
│ 5. Si erreur : "Échec" │
└──────────────────────────────────────────────┘🛠️ Code minimal
<!-- if / else if / else -->
@if (user(); as u) {
<p>Bonjour {{ u.name }}</p>
} @else if (loading()) {
<p>Chargement…</p>
} @else {
<p>Aucun utilisateur</p>
}
<!-- for + track obligatoire + index/last/even -->
<ul>
@for (todo of todos(); track todo.id; let i = $index, last = $last) {
<li [class.last]="last">{{ i + 1 }}. {{ todo.label }}</li>
} @empty {
<li>Aucune tâche</li>
}
</ul>
<!-- switch -->
@switch (status()) {
@case ('loading') { <spinner /> }
@case ('success') { <data-view [data]="data()" /> }
@case ('error') { <error-banner [msg]="error()" /> }
@default { <p>Inconnu</p> }
}
<!-- defer -->
@defer (on viewport; prefetch on idle) {
<product-recommendations />
} @placeholder (minimum 200ms) {
<div class="skeleton"></div>
} @loading (after 100ms; minimum 300ms) {
<p>Chargement des recommandations…</p>
} @error {
<p>Échec du chargement</p>
}import { Component, signal } from '@angular/core';
@Component({
selector: 'app-todos',
standalone: true,
// Plus besoin d'importer CommonModule pour ngIf/ngFor !
templateUrl: './todos.component.html',
})
export class TodosComponent {
protected readonly todos = signal([
{ id: 1, label: 'Apprendre @for' },
{ id: 2, label: 'Apprendre @defer' },
]);
protected readonly user = signal<{ name: string } | null>(null);
protected readonly loading = signal(false);
protected readonly status = signal<'loading' | 'success' | 'error'>('success');
}🎯 Patterns courants
1. Narrowing TypeScript avec as
@if (user(); as u) {
<!-- u est garanti non-null ici (narrowing TS) -->
<p>{{ u.email.toLowerCase() }}</p>
}Avec *ngIf="user as u" c'était possible mais souvent limité. Avec @if, le compilateur Angular utilise un vrai narrowing TypeScript dans le bloc — pas de cast implicite à any.
2. @for avec $index, $count, $first, $last, $even, $odd
@for (row of rows(); track row.id) {
<tr [class.even]="$even" [class.first]="$first">
<td>{{ $index + 1 }} / {{ $count }}</td>
<td>{{ row.label }}</td>
</tr>
}Les variables implicites sont préfixées par $. Plus de let i = index; let last = last; à rallonge.
3. track : la clé du delta efficace
<!-- BON : identifiant stable -->
@for (item of items(); track item.id) { … }
<!-- BON quand la liste est immutable / petite -->
@for (n of [1, 2, 3]; track $index) { … }
<!-- BON pour des primitives uniques -->
@for (tag of tags(); track tag) { … }
<!-- MAUVAIS : track sur l'objet (référence change → re-création complète) -->
@for (item of items(); track item) { … }Le track est obligatoire dans @for. Au compile-time, l'omettre est une erreur. C'est volontaire : sans track, Angular doit recréer tous les DOM nodes à chaque rendu, et c'est le bug perf n°1 des grandes listes.
4. @defer avec triggers combinés
@defer (on viewport; on hover(#trigger); prefetch on idle) {
<comments-section />
} @placeholder { <a #trigger>Voir les commentaires</a> }On peut combiner les triggers : ici le composant se charge dès qu'il entre dans le viewport ou qu'on survole le lien. Le JS est préchargé dès que le navigateur est idle.
Triggers disponibles : on idle, on viewport, on viewport(#ref), on interaction, on interaction(#ref), on hover, on hover(#ref), on immediate, on timer(2s), et le déclencheur conditionnel when expression() (qui réagit à un signal).
4bis. @for avec contextes imbriqués
@for (group of groups(); track group.id) {
<fieldset>
<legend>{{ group.label }}</legend>
@for (item of group.items; track item.id; let outerIndex = $index) {
<label>
<input type="checkbox" [checked]="item.selected" />
{{ outerIndex + 1 }}. {{ item.label }}
</label>
} @empty {
<p>Pas d'items dans ce groupe</p>
}
</fieldset>
} @empty {
<p>Aucun groupe</p>
}@empty est disponible à chaque niveau, et les variables $index/$first/etc. sont scopées au bloc @for courant — pas de conflit en imbriqué. Pour réutiliser l'index du parent dans l'enfant, le renommer avec let outerIndex = $index.
5. Defer "manuel" via when
@defer (when shouldLoad()) {
<heavy-widget />
}protected readonly shouldLoad = signal(false);
onUserAction() { this.shouldLoad.set(true); }Combinaison idiomatique avec un signal : on déclenche programmatiquement le chargement.
6. Deferred route (Angular 18+)
export const routes: Routes = [
{
path: 'admin',
loadComponent: () => import('./admin/admin.component').then(m => m.AdminComponent),
// Hints pour @defer-loaded blocks à l'intérieur de cette route
},
];Et dans le template du AdminComponent, les @defer peuvent référer à des composants extérieurs au chunk admin pour les charger encore plus tard.
7. @let (v19+) — variable locale au template
@let total = price() * qty() * (1 + tax());
@let discount = total * (couponPct() / 100);
<p>Total : {{ total }} €</p>
<p>Remise : {{ discount }} €</p>
<p>À payer : {{ total - discount }} €</p>@let capture une expression dans une variable locale scopée au bloc parent. Avantages :
- L'expression n'est évaluée qu'une fois par changement de dépendance.
- Améliore la lisibilité des templates avec des calculs répétés.
- Ne remplace pas
computed()côté classe :@letest local au template, pas réutilisable ailleurs.
<!-- Scoping : v n'existe pas dehors -->
@if (user(); as u) {
@let v = u.email.toLowerCase();
<p>{{ v }}</p>
}
<!-- ici v n'est plus accessible -->8. @switch sur un signal
@switch (theme()) {
@case ('light') { <link rel="stylesheet" href="light.css" /> }
@case ('dark') { <link rel="stylesheet" href="dark.css" /> }
@default { <link rel="stylesheet" href="system.css" /> }
}Pas de ngSwitchCase à importer, et l'évaluation === est stricte (pas de coercion type — l'ancien ngSwitch était parfois piégeux).
🔄 Versions — Angular 16 / 17 / 18 / 19 / 20
| Version | Apport principal sur le control flow |
|---|---|
| 16 (mai 2023) | Toujours *ngIf/*ngFor/*ngSwitch via directives. Pas de syntaxe @. |
| 17 (nov 2023) | @if, @for, @switch, @defer stables. Schematic de migration ng generate @angular/core:control-flow. Performances @for ~90% plus rapide que *ngFor selon le blog officiel. |
| 18 (mai 2024) | Améliorations @defer (meilleurs hints SSR/hydration). Stabilité accrue de la migration auto. |
| 19 (nov 2024) | @let dans les templates (déclaration de variable locale, ex. @let v = expensive();). Hydration incrémentale exploite @defer pour rendre serveur + hydrater à la demande client. |
| 20 (mai 2025) | Le control flow devient la syntaxe officielle dans toutes les docs et schematics. *ngIf/*ngFor toujours supportés mais marqués "legacy" dans la doc. |
Timeline pratique :
v17 ──── @if / @for / @switch / @defer STABLES
v18 ──── meilleur SSR avec @defer
v19 ──── @let dans les templates + hydration incrémentale
v20 ──── le control flow natif est partout dans la docÀ noter : la migration auto fonctionne sur la plupart des templates, mais peut laisser des cas tordus à corriger à la main (ex. *ngIf imbriqué avec un ng-template partagé).
⚠️ Pitfalls
- Oublier
tracksur@for— Erreur de compilation. Si tu n'as pas d'id, choisistrack $indexquand la liste est stable, outrack itempour des primitives. track $indexsur liste réordonnée — Casse le DOM diff : les éléments à un index donné restent attachés alors que les data ont changé. Conséquence :[checked],[(ngModel)], focus sont mal réattribués. Toujours préférer un id métier.@deferviewport timing — Le viewport est observé viaIntersectionObserver. Si ton placeholder est très petit ou caché, le bloc peut ne jamais se charger. Soigner le placeholder pour qu'il ait une taille réaliste.@deferminimum vs after —(after 100ms)= on attend 100ms avant d'afficher le@loading, pour éviter le flash.(minimum 300ms)= on garde le@loadingau moins 300ms, pour éviter qu'il clignote. Bien lire les options.- Animations + control flow — Les directives
[@animation]continuent de fonctionner, mais les triggers:enter/:leavequi dépendaient de la durée de cycle*ngIfpeuvent se comporter différemment (création/destruction au même tick). Tester. @deferet SSR — Avant Angular 19,@deferpouvait rendre le placeholder seulement côté serveur. Depuis 19, l'hydration incrémentale rend le contenu serveur et l'hydrate progressivement.@ifsans else — Pas de problème, mais sois explicite pour la lisibilité dans les grands templates :@if (…) { … } @else { }(vide) est parfois plus clair pour signaler l'intention.- Variables locales et scope — Les
let x = $indexdu*ngFordeviennentlet i = $indexdans@for. La syntaxe est@for (item of list; track item.id; let i = $index, last = $last). - Migration auto laissée incomplète — Le schematic ne migre que les templates. Les commentaires et certains cas multi-templates restent à finir à la main. Toujours faire une revue PR après la migration.
@let(v19+) est local au template — Pas une remplaçant d'un computed. Pour de la dérivée partagée entre composants ou cachée, utiliser uncomputed()côté classe.@forqui mute la source — Modifier l'array source pendant le rendu d'un@forproduit des comportements imprévisibles. Toujours produire un nouvel array (signal.update(a => [...a, x])).@deferavec composants lourds importés ailleurs — Si un composant<heavy/>est déjà importé dans le composant parent viaimports: [...], il fait partie du chunk parent et@defern'aura aucun effet. Bien isoler le composant deferred dans son propre fichier non importé statiquement.@emptymal positionné —@emptydoit être adjacent au@forcorrespondant. Le mettre ailleurs donne une erreur de parse.@switchsans@default— Légal mais piégeur : si aucun case ne matche, rien n'est rendu silencieusement. Préférer un@defaultmême vide pour signaler l'intention.
🚀 Performances @for vs *ngFor
Le team Angular a publié des benchmarks montrant des gains de ~80-90% sur des listes de 1000+ items réordonnés. Les raisons :
- Instructions de rendu directes : pas de
TemplateRef/ViewContainerRefintermédiaire. trackobligatoire : le diff est toujours optimisé, pas de fallback "diff par référence" coûteux.- Less indirection : moins de zone-related work, mieux compatible zoneless.
Mesure indicative sur une liste de 5000 items mélangés (Chrome 120, M1 Pro) :
| Op | *ngFor (Angular 16) | @for (Angular 20) |
|---|---|---|
| Initial render | ~120 ms | ~70 ms |
| Re-order complet | ~200 ms | ~40 ms |
| Append 100 | ~25 ms | ~10 ms |
Ce n'est pas le seul critère, mais sur des grilles éditables (admin, dashboards), c'est sensible visuellement.
Méthodo benchmark — ne reportez jamais un chiffre sans dire comment il est mesuré. Pour comparer
@forvs*ngForhonnêtement : même device, même build (ng build --configuration production, AOT, pas de devtools ouverts), warm-up de 3 runs jetés, médiane sur 20 runs, et mesure viaperformance.measure()autour dudetectChanges(), pas au chronomètre. Un benchmark fait enng serve(JIT, source maps, HMR) ment de 2-3×. C'est exactement le genre de rigueur qu'on attend d'un senior quand il défend un choix techno en revue d'archi.
🔬 Sous le capot — comment @for réconcilie réellement
Comprendre l'algorithme de diff est ce qui sépare « je sais écrire track» de « je sais débugger pourquoi ma liste flicker ». Voici le modèle mental exact.
À chaque change detection, @for reçoit la nouvelle collection et possède la map des vues vivantes indexées par leur clé de track. Il exécute un diff à la Myers simplifié :
ancien : [A(k=1)] [B(k=2)] [C(k=3)] ← vues DOM existantes (LContainer)
nouveau : k=2, k=3, k=1, k=4 ← valeurs produites par l'expression
Pour chaque item nouveau, dans l'ordre :
k=2 → vue existe → MOVE la vue de B en position 0 (move, pas recreate)
k=3 → vue existe → MOVE la vue de C en position 1
k=1 → vue existe → MOVE la vue de A en position 2
k=4 → vue absente → CREATE une nouvelle vue + exécute le template
Vues restées orphelines (aucune clé) → DESTROY (ngOnDestroy + retrait DOM)Trois opérations seulement : CREATE / MOVE / DESTROY. La clé track est ce qui décide si on est dans le cas MOVE (rapide, on déplace un noeud DOM + on met juste à jour les bindings) ou CREATE/DESTROY (coûteux, on rejoue tout le template + cycle de vie complet).
track item.id → identité métier stable
┌────────────────────────────────┐
réordonner : │ MOVE ×N (pas de destroy) │ ✅ rapide, état préservé
└────────────────────────────────┘
track $index → identité = position
┌────────────────────────────────┐
réordonner : │ 0 MOVE, on patche les bindings │ ⚠️ DOM stable mais
│ in-place : la vue index 0 garde │ l'ÉTAT interne du
│ son DOM, on réécrit son contenu │ composant ne suit pas
└────────────────────────────────┘
track item → identité = référence objet
┌────────────────────────────────┐
nouvel array │ aucune clé matche → DESTROY tout │ ❌ recrée tout le sous-arbre
(immutable !) : │ + CREATE tout │ à chaque tick
└────────────────────────────────┘Le piège mortel de track $index : le DOM reste (pas de flicker visible) mais Angular réutilise la vue index 0 pour le nouvel item index 0. Si cette vue contient un <input [(ngModel)]>, un @defer à moitié chargé, une vidéo en lecture, un composant avec du state local, ou le focus clavier — cet état reste collé à la position, pas à la donnée. L'utilisateur supprime la ligne 2, et c'est le contenu saisi de la ligne 3 qui « remonte » visuellement dans le champ de la ligne 2. Bug classique, invisible aux tests qui ne vérifient que le textContent.
Règle de décision d'un senior :
| Situation | track à choisir | Pourquoi |
|---|---|---|
| Entités avec id serveur | track item.id | Identité stable = MOVE, état préservé |
| Liste read-only jamais réordonnée (menu statique) | track $index | Pas d'id, pas de mutation → sûr |
Tableau de primitives uniques (string[], number[]) | track item | La primitive est l'identité |
Primitives avec doublons (['a','a','b']) | track $index | track item planterait (clés dupliquées) |
| Données sans id mais stables en mémoire | générer un id (crypto.randomUUID() à la création) | Ne jamais s'appuyer sur la référence |
Coût caché de la fonction track : elle est appelée pour chaque item à chaque change detection. Si vous écrivez track computeHash(item) avec un calcul lourd, vous le payez sur toute la liste à chaque tick. La fonction track doit être O(1) et pure — lire une propriété, pas calculer.
🌐 Hydration incrémentale (v19+)
Avec provideClientHydration(withIncrementalHydration()), Angular 19+ génère côté SSR un HTML complet, mais n'hydrate côté client que les blocs @defer au fur et à mesure de leurs triggers. Conséquences :
- Le JS du composant deferred n'est téléchargé qu'au besoin.
- L'interactivité d'un bloc deferred peut être différée jusqu'au scroll/clic.
- Le TTI (Time To Interactive) chute drastiquement sur les pages riches.
// app.config.ts
import { provideClientHydration, withIncrementalHydration } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
provideClientHydration(withIncrementalHydration()),
// …
],
};C'est ce qui transforme @defer d'une simple optim de bundle en un vrai levier d'archi SSR-first.
🧪 Testing — TestBed + harnesses
import { TestBed } from '@angular/core/testing';
import { TodosComponent } from './todos.component';
describe('TodosComponent', () => {
it('affiche un message quand la liste est vide', () => {
const fixture = TestBed.createComponent(TodosComponent);
fixture.componentInstance['todos'].set([]);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Aucune tâche');
});
it('rend tous les items avec le bon track', () => {
const fixture = TestBed.createComponent(TodosComponent);
fixture.componentInstance['todos'].set([
{ id: 1, label: 'a' }, { id: 2, label: 'b' },
]);
fixture.detectChanges();
const items = fixture.nativeElement.querySelectorAll('li');
expect(items.length).toBe(2);
expect(items[0].textContent).toContain('a');
});
});Tester un @defer
import { DeferBlockBehavior, DeferBlockState } from '@angular/core/testing';
it('rend le placeholder par défaut puis le contenu', async () => {
TestBed.configureTestingModule({
imports: [DashboardComponent],
deferBlockBehavior: DeferBlockBehavior.Manual, // contrôle manuel
});
const fixture = TestBed.createComponent(DashboardComponent);
fixture.detectChanges();
const blocks = await fixture.getDeferBlocks();
expect(blocks.length).toBe(1);
// Vérifie le placeholder
await blocks[0].render(DeferBlockState.Placeholder);
expect(fixture.nativeElement.textContent).toContain('Loading…');
// Force l'état "complete"
await blocks[0].render(DeferBlockState.Complete);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('product-recommendations')).toBeTruthy();
});DeferBlockBehavior.Manual est essentiel : sans ça, le bloc est piloté par les triggers du navigateur, qui ne se déclenchent pas dans un environnement de test.
Vérifier la stabilité du DOM via track
it('réutilise les noeuds DOM quand on ré-ordonne par id', () => {
const fixture = TestBed.createComponent(TodosComponent);
fixture.componentInstance['todos'].set([
{ id: 1, label: 'a' }, { id: 2, label: 'b' },
]);
fixture.detectChanges();
const first = fixture.nativeElement.querySelectorAll('li')[0];
fixture.componentInstance['todos'].set([
{ id: 2, label: 'b' }, { id: 1, label: 'a' },
]);
fixture.detectChanges();
const newFirst = fixture.nativeElement.querySelectorAll('li')[0];
// Le noeud du li id=2 est maintenant en première position, l'ancien id=1 toujours en DOM
expect(newFirst.textContent).toContain('b');
});🎬 Cas d'usage concrets
Scénario 1 — SaaS RH, liste des candidats avec @for et tri par statut
Une plateforme de recrutement affiche, par offre, jusqu'à 2 000 candidatures. L'ancienne implémentation utilisait *ngFor avec trackBy et un ng-container enveloppé dans des *ngIf imbriqués pour les bandeaux d'état (« pas encore contacté », « en attente d'entretien », etc.). Le code template faisait 180 lignes pour une fonctionnalité conceptuellement simple.
L'équipe migre vers @for et @if/@else if/@else. Le tri par statut se fait via plusieurs @for consécutifs filtrés par computed, ce qui rend explicite l'ordre d'affichage. Le track candidate.id est obligatoire (le compilateur le rappelle), ce qui élimine la classe d'erreur où un dev oubliait trackBy et provoquait des re-créations massives de DOM. Sur une liste de 800 candidats, la mise à jour d'un statut (drag-and-drop entre colonnes) ne recrée qu'un seul <li> au lieu de toute la sous-section.
Bénéfice annexe : le bloc @empty remplace les *ngIf="candidates.length === 0" placés à côté du *ngFor. La logique « rien à afficher » est dans le même bloc que la boucle, ce qui réduit la charge cognitive en revue de code.
Scénario 2 — E-commerce, page produit avec @defer
Un site de mode B2C a une fiche produit qui embarque : galerie d'images, sélecteur de taille, encart livraison, avis clients (avec photos), recommandations IA, suggestions de tenues complètes. Les avis et les recommandations sont des sections lourdes (graphique de notes, carrousel d'images compressées, appel API distinct) mais visibles uniquement en bas de page.
L'équipe utilise @defer (on viewport) pour les avis et @defer (on idle) pour les recommandations. Les @placeholder montrent des skeletons aux dimensions correctes (évite le CLS), les @loading ajoutent un spinner après 200 ms, et les @error proposent un bouton « Réessayer ». Le bundle initial de la fiche tombe de 480 KB à 190 KB. Le LCP passe de 2,4 s à 1,1 s sur 4G simulé. Les avis et recommandations ne se chargent que si l'utilisateur scrolle.
Important : l'équipe a découvert qu'@defer (on hover) n'est pas adapté au mobile (pas de hover). Pour les badges produits cliquables, ils utilisent @defer (on interaction) qui couvre touch + click.
Scénario 3 — Cabinet juridique, recherche de jurisprudence avec @if/@else multi-états
Le moteur de recherche de jurisprudence d'un cabinet renvoie 4 états : idle (formulaire vide), loading (recherche en cours), empty (zéro résultat), results (liste). Avec *ngIf il fallait soit trois templates <ng-template #loading>, <ng-template #empty>, <ng-template #results> chaînés en else, soit imbriquer plusieurs *ngIf — verbeux et difficile à suivre.
Avec @if (state() === 'loading') { } @else if (state() === 'empty') { } @else if (state() === 'results') { } @else { }, la logique tient en 15 lignes, lisible de haut en bas. L'équipe a aussi pu utiliser @switch sur l'enum d'état, ce qui leur rappelle la programmation Pascal/Java et plaît à l'équipe legacy.
Le contexte : ce sont des avocats qui touchent au code (formations internes). La syntaxe @if ressemble à du JavaScript natif, ce qui réduit la friction quand ils relisent les PRs.
🛠️ Exemple end-to-end
Use case : page produit e-commerce avec liste paginée d'avis chargée via @defer (on viewport), états explicites (loading, error, empty, results) gérés par @if/@else if, et boucle de variantes produit en @for avec track.
// product-detail.types.ts
export interface ProductVariant {
id: string;
size: string;
color: string;
stock: number;
priceCents: number;
}
export interface Review {
id: string;
author: string;
rating: 1 | 2 | 3 | 4 | 5;
comment: string;
createdAt: string;
}
export type ReviewsState =
| { kind: 'loading' }
| { kind: 'error'; message: string }
| { kind: 'empty' }
| { kind: 'results'; reviews: Review[]; total: number };// reviews.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, catchError, map, of } from 'rxjs';
import { Review, ReviewsState } from './product-detail.types';
@Injectable({ providedIn: 'root' })
export class ReviewsService {
private readonly http = inject(HttpClient);
loadByProduct(productId: string): Observable<ReviewsState> {
return this.http
.get<{ items: Review[]; total: number }>(`/api/products/${productId}/reviews`)
.pipe(
map((res) =>
res.total === 0
? ({ kind: 'empty' } as ReviewsState)
: ({ kind: 'results', reviews: res.items, total: res.total } as ReviewsState),
),
catchError(() => of({ kind: 'error', message: 'Avis indisponibles.' } as ReviewsState)),
);
}
}// product-detail.component.ts
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { switchMap } from 'rxjs';
import { CurrencyPipe, DatePipe } from '@angular/common';
import { ReviewsService } from './reviews.service';
import { ProductVariant } from './product-detail.types';
@Component({
selector: 'app-product-detail',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CurrencyPipe, DatePipe],
template: `
<h1>{{ name() }}</h1>
<h2>Variantes disponibles</h2>
@if (inStockVariants().length > 0) {
<ul>
@for (v of inStockVariants(); track v.id) {
<li>
{{ v.size }} — {{ v.color }} — {{ v.priceCents / 100 | currency: 'EUR' }}
({{ v.stock }} en stock)
</li>
} @empty {
<li>Aucune variante en stock.</li>
}
</ul>
} @else {
<p>Produit en rupture, alertes possibles via le bouton ci-dessous.</p>
}
<section>
<h2>Avis clients</h2>
@defer (on viewport) {
@if (reviewsState(); as s) {
@if (s.kind === 'loading') {
<p>Chargement…</p>
} @else if (s.kind === 'error') {
<p class="error">{{ s.message }}</p>
} @else if (s.kind === 'empty') {
<p>Aucun avis pour le moment. Soyez le premier !</p>
} @else {
<p>{{ s.total }} avis</p>
<ul>
@for (r of s.reviews; track r.id) {
<li>
<strong>{{ r.author }}</strong> — {{ r.rating }}/5
<span>({{ r.createdAt | date: 'shortDate' }})</span>
<p>{{ r.comment }}</p>
</li>
}
</ul>
}
}
} @placeholder (minimum 100ms) {
<div class="skeleton" style="height:240px"></div>
} @loading (after 200ms) {
<p>Récupération des avis…</p>
} @error {
<p>Avis temporairement indisponibles.</p>
}
</section>
`,
})
export class ProductDetailComponent {
private readonly reviews = inject(ReviewsService);
readonly productId = input.required<string>();
readonly name = input.required<string>();
readonly variants = input.required<ProductVariant[]>();
protected readonly inStockVariants = computed(() =>
this.variants().filter((v) => v.stock > 0),
);
// On NE lit PAS this.productId() dans un initialiseur de champ :
// un input required n'est pas encore résolu à la construction (NG0950).
// On dérive un Observable de l'input via toObservable(), puis switchMap
// (re)lance la requête quand productId change, et toSignal expose l'état.
protected readonly reviewsState = toSignal(
toObservable(this.productId).pipe(
switchMap((id) => this.reviews.loadByProduct(id)),
),
);
}Cette page combine : un @for avec @empty pour les variantes, un @if/@else au niveau supérieur (stock global), un @defer (on viewport) pour différer le rendu des avis, des triggers @placeholder/@loading/@error, et une machine d'état lisible via @if/@else if. Le tout en OnPush, signaux uniquement.
Attention —
@deferdiffère le rendu, pas l'abonnement. IcitoSignals'abonne dès la construction du composant : la requête HTTP part donc immédiatement, pas quand le bloc@deferentre dans le viewport. Pour réellement différer la requête jusqu'à l'hydratation du bloc, il faut isoler la logique de chargement dans un composant enfant placé à l'intérieur du@defer(le composant enfant n'est instancié qu'au déclenchement du trigger), ou garder l'effect/abonnement derrière un signalvisibleactivé par le bloc. C'est précisément le piège travaillé à l'exercice 3.
🤖 Control flow pour une UI d'agent IA (streaming)
C'est le cas où le control flow Angular brille, et c'est exactement le type d'UI que vous allez construire au-dessus de vos agents (Claude claude-opus-4-8 / claude-sonnet-4-6 / claude-haiku-4-5). Une conversation agentique, c'est trois choses qui mappent une-pour-une sur le control flow :
- un buffer de messages append-only →
@foravec untracksolide, - une trace d'appels d'outils (union discriminée d'états
pending|running|streaming|done|error) →@switch, - des états de stream (idle/streaming/done/error/aborted) au niveau conversation →
@if/@else if.
Modèle de données (union discriminée)
// agent.types.ts
export type ToolCall =
| { id: string; name: string; status: 'pending' }
| { id: string; name: string; status: 'running'; startedAt: number }
| { id: string; name: string; status: 'streaming'; partial: string }
| { id: string; name: string; status: 'done'; result: string; ms: number }
| { id: string; name: string; status: 'error'; message: string };
export interface ChatMessage {
id: string; // id stable côté client (clé de @for)
role: 'user' | 'assistant';
text: string; // accumulé token par token
toolCalls: ToolCall[];
done: boolean;
}
export type StreamState = 'idle' | 'streaming' | 'done' | 'error' | 'aborted';Store en signals (append-only + coalescing rAF, zoneless-friendly)
Le piège n°1 d'une UI de streaming : appeler signal.set() à chaque token (60-100 fois/seconde) déclenche autant de change detections. Sous zoneless on coalesce les deltas dans un buffer et on flush une fois par frame avec requestAnimationFrame.
// chat.store.ts
import { Injectable, signal } from '@angular/core';
import { ChatMessage, StreamState } from './agent.types';
@Injectable({ providedIn: 'root' })
export class ChatStore {
readonly messages = signal<ChatMessage[]>([]);
readonly streamState = signal<StreamState>('idle');
private pendingText = new Map<string, string>(); // messageId -> delta accumulé
private rafId: number | null = null;
appendToken(messageId: string, token: string): void {
this.pendingText.set(messageId, (this.pendingText.get(messageId) ?? '') + token);
if (this.rafId !== null) return; // déjà programmé pour cette frame
this.rafId = requestAnimationFrame(() => this.flush());
}
private flush(): void {
this.rafId = null;
const deltas = this.pendingText;
this.pendingText = new Map();
// une seule mutation de signal par frame → une seule CD
this.messages.update((msgs) =>
msgs.map((m) =>
deltas.has(m.id) ? { ...m, text: m.text + deltas.get(m.id)! } : m,
),
);
}
}Le template — control flow pur
<!-- buffer de messages : track sur l'id stable, JAMAIS $index
(sinon le message en cours de streaming "saute" quand on prepend un message système) -->
@for (msg of store.messages(); track msg.id) {
<article [class.assistant]="msg.role === 'assistant'">
<!-- markdown sanitizé : voir note DomSanitizer plus bas -->
<div [innerHTML]="msg.text | markdown"></div>
<!-- trace d'outils : @switch sur l'union discriminée -->
@for (call of msg.toolCalls; track call.id) {
<div class="tool-step">
@switch (call.status) {
@case ('pending') { <span>⏳ {{ call.name }} en file…</span> }
@case ('running') { <span>⚙️ {{ call.name }} en cours…</span> }
@case ('streaming') { <pre>{{ call.partial }}</pre> }
@case ('done') { <span>✅ {{ call.name }} ({{ call.ms }}ms)</span> }
@case ('error') { <span class="err">❌ {{ call.message }}</span> }
}
</div>
}
<!-- curseur clignotant tant que ce message n'est pas terminé -->
@if (!msg.done && store.streamState() === 'streaming') {
<span class="cursor" aria-hidden="true">▋</span>
}
</article>
} @empty {
<p class="empty">Posez une question pour démarrer.</p>
}
<!-- état global du stream + bouton Stop -->
@switch (store.streamState()) {
@case ('streaming') {
<button (click)="stop()">⏹ Arrêter</button>
}
@case ('error') {
<p class="err">La génération a échoué. <button (click)="retry()">Réessayer</button></p>
}
@case ('aborted') {
<p>Génération interrompue.</p>
}
@default {}
}
<!-- un rendu lourd (graphe, table de données générée par l'agent) qu'on ne paie
que s'il apparaît : @defer évite de charger D3/markdown-it tant qu'inutile -->
@defer (when store.streamState() === 'done'; on viewport) {
<agent-result-chart [messages]="store.messages()" />
} @placeholder {
<div class="skeleton" style="height:200px"></div>
}Lecture du flux SSE (fetch + ReadableStream) avec Stop client ET serveur
EventSource ne supporte que GET et pas les headers custom (auth) : pour une vraie UI agent on lit un ReadableStream via fetch + getReader(), ce qui donne aussi un AbortController natif pour le bouton Stop. Annuler côté client ne suffit pas : sans propager l'abort au serveur, votre NestJS continue de brûler des tokens Claude. Le signal du fetch ferme la connexion HTTP, ce que le serveur observe (req.on('close') / AbortSignal côté handler) pour appeler stream.controller.abort() sur le SDK Anthropic.
// chat.service.ts
import { Injectable, inject } from '@angular/core';
import { ChatStore } from './chat.store';
@Injectable({ providedIn: 'root' })
export class ChatService {
private readonly store = inject(ChatStore);
private controller: AbortController | null = null;
async send(messageId: string, prompt: string): Promise<void> {
this.controller = new AbortController();
this.store.streamState.set('streaming');
try {
const res = await fetch('/api/agent/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt }),
signal: this.controller.signal, // ← propage l'abort au réseau
});
if (!res.body) throw new Error('no stream');
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// parse les events SSE "data: {json}\n\n"
const events = buffer.split('\n\n');
buffer = events.pop() ?? '';
for (const evt of events) {
const line = evt.replace(/^data: /, '').trim();
if (!line || line === '[DONE]') continue;
const delta = JSON.parse(line) as { token?: string };
if (delta.token) this.store.appendToken(messageId, delta.token);
}
}
this.store.streamState.set('done');
} catch (e) {
// abort() rejette avec AbortError → ce n'est PAS une erreur métier
this.store.streamState.set(
(e as Error).name === 'AbortError' ? 'aborted' : 'error',
);
} finally {
this.controller = null;
}
}
stop(): void {
this.controller?.abort(); // ferme la socket → le serveur arrête le stream Claude
}
}Markdown + sécurité (DomSanitizer)
La sortie d'un LLM est du contenu non fiable : un utilisateur peut injecter <img src=x onerror=...> dans son prompt et demander au modèle de le recracher. Ne jamais binder [innerHTML] sans assainir. Pipe minimal :
import { Pipe, PipeTransform, inject, SecurityContext } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
import { marked } from 'marked';
@Pipe({ name: 'markdown', standalone: true })
export class MarkdownPipe implements PipeTransform {
private readonly sanitizer = inject(DomSanitizer);
transform(value: string): string {
const html = marked.parse(value, { async: false }) as string;
// sanitize() retire scripts/handlers ; pour du contenu LLM,
// préférer DOMPurify en amont si on veut garder des balises riches.
return this.sanitizer.sanitize(SecurityContext.HTML, html) ?? '';
}
}Pourquoi le control flow change la donne ici : sous l'ancien
*ngFor/*ngSwitchCase, chaque token déclenchait une réévaluation de directive structurelle avec sonTemplateRef. Sous@forzoneless + signals + coalescing rAF, on rend des conversations de plusieurs centaines de messages qui streament à 60 fps sans jank, parce que (a) letrack msg.idne touche que le message muté, (b) une seule CD par frame, (c)@defergarde les renderers lourds hors du chemin critique tant que le stream tourne.
🧭 Comment un staff engineer raisonne là-dessus
Le control flow n'est pas qu'une question de syntaxe — c'est un point de décision d'architecture. La grille de raisonnement :
| Axe | La bonne question | Réflexe senior |
|---|---|---|
| Identité | « Qu'est-ce qui identifie un item au-delà de sa position ? » | Si la réponse est « rien », c'est un signal que le modèle de données est faible — ajouter un id à la source, pas masquer avec track $index. |
| État vs donnée | « Y a-t-il de l'état DOM/composant attaché à chaque ligne ? » | Présence de [(ngModel)], focus, media, @defer interne → track par id obligatoire, jamais par index. |
| Coût de rendu | « Ce bloc est-il sur le chemin critique du LCP ? » | En-dessous du fold ou conditionnel rare → @defer. Au-dessus du fold → surtout pas (le placeholder ajoute du CLS). |
| Destruction | « Perdre l'état au toggle est-il acceptable ? » | @if détruit/recrée. Si l'état doit survivre (form à moitié rempli, onglet) → [hidden]/CSS ou garder monté. |
| Mutabilité | « La source est-elle mutée en place ou remplacée ? » | Signals + control flow supposent l'immutabilité. Muter en place = diff cassé silencieusement. |
| Granularité CD | « Combien de fois ce control flow réévalue-t-il par seconde ? » | Streaming/animations → coalescer (rAF), track O(1), @let pour mémoriser, OnPush/zoneless. |
La phrase à retenir en revue d'archi : « le control flow ne décide pas seulement de ce qui s'affiche, mais de ce qui vit, meurt, et conserve son état dans l'arbre de vues. »
🔁 Quand utiliser / éviter
| Utiliser quand | Éviter quand |
|---|---|
| Nouveau projet Angular 17+ | Projet bloqué sur Angular ≤ 16 (le linter ne reconnaît pas la syntaxe) |
Listes longues (@for + track perf) | Lib qui doit générer du HTML compatible Angular 14-16 (rester sur *ngFor) |
UI conditionnelle complexe (@if/@else if/@else) | Cas où une directive structurelle custom est déjà en place et bien testée |
Lazy chunks UI (@defer au-dessus du fold) | Composant léger : @defer ajoute du JS pour quelque chose qui se rend en 1ms |
| Code lisible et tree-shakable | Templates partagés via ng-template complexes : la migration peut être douloureuse, à étaler |
🧩 Cohabitation pendant la migration
Pendant qu'on migre une app de *ngIf/*ngFor vers @if/@for, les deux syntaxes peuvent cohabiter dans le même template :
<!-- legacy -->
<div *ngIf="user as u">{{ u.name }}</div>
<!-- nouveau -->
@if (other(); as o) {
<p>{{ o.label }}</p>
}Mais c'est désagréable à lire. Convention recommandée :
- Migrer un fichier entier d'un coup.
- Lancer le schematic par dossier pour rester cohérent.
- Si le diff est trop gros, créer une PR uniquement de migration sans changement de logique pour faciliter la revue.
# Migrer tout le dossier features/admin
ng generate @angular/core:control-flow --path=src/app/features/admin🎬 Cycle de vie et control flow
Quand un @if bascule de true à false, le sous-arbre est détruit (les ngOnDestroy se déclenchent) puis recréé s'il revient à true. Identique à *ngIf.
@Component({
selector: 'app-child',
standalone: true,
template: `<p>child</p>`,
})
export class ChildComponent {
constructor() { console.log('child created'); }
ngOnDestroy() { console.log('child destroyed'); }
}@if (show()) { <app-child /> }Toggle show() plusieurs fois affichera created/destroyed à chaque transition. C'est important pour les composants à état (formulaires, médias) : envisager [hidden] plutôt que @if si on veut préserver l'état.
@defer a un comportement différent : une fois le bloc rendu, il reste rendu (sauf si on combine avec un @if parent).
🔗 Liens
- Angular Docs — Control flow
- Angular Docs — Deferrable views
- Blog — Introducing Angular v17 (control flow)
- Migration schematic
- Talk : Inside the new control flow (ng-conf)
🏋️ Exercices
Progression : implémenter → rendre production-grade → casser puis réparer. Tout en signals + OnPush/zoneless, control flow natif uniquement.
Exercice 1 — Machine d'état à 4 branches (échauffement)
Objectif : rendre un composant <remote-search> qui passe par idle → loading → empty | error | results en @if/@else if, puis le réécrire en @switch sur une union discriminée.
Indice/Solution : modéliser signal<SearchState> où SearchState = {kind:'idle'} | {kind:'loading'} | {kind:'empty'} | {kind:'error',msg} | {kind:'results',items}. Le narrowing du kind dans chaque @case doit donner accès aux champs spécifiques sans cast. Comparer la lisibilité des deux versions : @switch gagne dès 3 branches mutuellement exclusives.
Exercice 2 — Démontrer le bug track $index (le faire mal, puis voir)
Objectif : prouver à toi-même que track $index corrompt l'état. Liste de lignes éditables, chacune avec un <input [(ngModel)]> non lié à la donnée. Saisir du texte dans 3 lignes, supprimer la 1ʳᵉ.
Indice/Solution : avec track $index, le texte saisi « remonte » — la vue index 0 est réutilisée pour l'ex-index 1. Bascule sur track row.id : le bon <input> disparaît. Écris un test qui échoue avec $index : après suppression, assert que input.value de la ligne survivante est intact (vérifier value, pas textContent — c'est tout le piège).
Exercice 3 — Page produit @defer-first, budget LCP (production-grade)
Objectif : reprendre l'exemple end-to-end et ajouter un budget de performance : LCP < 1,2 s sur 4G simulé, CLS < 0,1, et la requête avis ne part que quand le bloc s'hydrate.
Indice/Solution : @placeholder aux dimensions exactes du contenu final (mesurer, fixer height → CLS≈0). @loading (after 200ms) pour éviter le flash sur connexion rapide. Pour que la requête ne parte qu'à l'hydratation, ne pas appeler toSignal() au constructeur (il s'abonne immédiatement) — déclencher la charge dans le bloc via un composant enfant lazy ou un effect gardé par un signal visible. Vérifier dans le Network tab que /reviews ne part qu'au scroll.
Exercice 4 — UI de chat agent en streaming (intégration)
Objectif : construire le composant chat de la section IA : buffer append-only en @for track id, trace d'outils en @switch, coalescing rAF, bouton Stop câblé sur AbortController.
Indice/Solution : le test qui compte — fais streamer 500 messages avec un faux flux qui émet un token toutes les 5 ms ; vérifie via performance que le nombre de change detections par seconde reste borné (≈ refresh rate) et non égal au nombre de tokens. Sans requestAnimationFrame, tu verras ~200 CD/s ; avec, ~60. Ajoute l'assertion que stop() met l'état à 'aborted' et pas 'error' (discriminer AbortError).
Exercice 5 — Casse le @defer, puis répare (debug)
Objectif : reproduire « le @defer ne se déclenche jamais » puis trois causes distinctes de l'inefficacité.
Indice/Solution : (a) @placeholder de height:0 → l'IntersectionObserver ne déclenche jamais on viewport ; donner une taille réelle. (b) Le composant deferred est importé dans imports:[...] du parent → il est dans le chunk parent, @defer n'économise rien ; vérifie avec source-map-explorer que <heavy/> est bien dans un chunk séparé. (c) track lourd : remplace track hash(item) par track item.id et mesure la chute du temps de CD. Écris un test DeferBlockBehavior.Manual qui rend Placeholder puis Complete et assert chaque transition.
Exercice 6 — Lib compatible Angular 16-20 (contrainte de portée)
Objectif : une lib partagée doit fonctionner sur des apps Angular 16 (sans control flow natif) ET 20. Écrire un composant de liste réutilisable.
Indice/Solution : tu ne peux pas utiliser @for dans le code publié si tu cibles 16 (le compilo le rejette). Stratégie : rester sur *ngFor/trackBy dans la lib, exposer une API propre, et documenter que les consommateurs migrent leur app. Alternative : publier deux entry points conditionnés par la version Angular du peer dependency. Discuter le tradeoff maintenance vs perf — c'est une vraie question d'archi de lib, pas un détail.
🎤 En entretien
Q : Pourquoi track est-il obligatoire dans @for alors que trackBy était optionnel dans *ngFor ? R : Parce que sans clé d'identité, le diff retombe sur la comparaison par référence d'objet, qui recrée tout le sous-arbre dès qu'on remplace l'array (le cas normal avec des signals immutables) — c'est le bug perf n°1 des grandes listes. Le rendre obligatoire force le dev à expliciter l'identité au compile-time, transformant une erreur runtime silencieuse en erreur de compilation.
Q : Quelle est la différence concrète entre track item.id et track $index sur une liste réordonnée avec des <input> ? R : track item.id produit des opérations MOVE — le DOM et l'état du composant suivent la donnée. track $index lie l'identité à la position : le DOM reste mais l'état (saisie, focus, media) reste collé à la position, donc l'état « remonte » quand on supprime un élément. Invisible aux tests qui ne lisent que textContent.
Q : @defer vs loadComponent d'une route lazy — quand l'un, quand l'autre ? R : loadComponent lazy-load au niveau route (changement d'URL). @defer lazy-load au niveau bloc de template à l'intérieur d'une page déjà chargée, déclenché par viewport/idle/interaction/hover/timer/when. On utilise @defer pour découper une page lourde sous le fold sans changer de navigation, et avec l'hydration incrémentale (v19+) pour différer aussi l'hydratation côté SSR.
Q : Comment @if se comporte-t-il vis-à-vis du cycle de vie, et quand préférer [hidden] ? R : @if détruit le sous-arbre quand la condition passe à false (ngOnDestroy se déclenche) et le recrée à true — l'état interne est perdu. Si on veut préserver l'état (formulaire à moitié rempli, vidéo en lecture, onglet), @if est le mauvais outil : utiliser [hidden]/CSS pour masquer sans démonter, en acceptant que le composant reste monté et continue son change detection.
📌 Récap final
@if/@else if/@else= syntaxe native, narrowing TS, plus besoin d'importerNgIf.@for…track …=trackobligatoire ; performances ~90% supérieures à*ngFor;@emptypour le cas liste vide ; variables$index/$first/$last/$even/$odd/$count.@switch/@case/@default= égalité stricte, pas de directive à importer.@defer= lazy block déclaratif avec triggers (on viewport,on idle,on hover,on interaction,when …), prefetch séparé, et états@placeholder/@loading/@error.@let(v19+) = variable locale au template ; pratique pour mémoriser une expression coûteuse une seule fois.- Migration =
ng generate @angular/core:control-flowautomatise 95% du travail. - En 2026, écrire du
*ngIfdans un nouveau composant est un code smell de junior : passer au nouveau control flow par défaut.