📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

~8 min de lecture

@for et track : 5 erreurs qui rebuild ton DOM à chaque tick

Tu charges une liste de 800 lignes. Tu scrolles. Ton navigateur freeze à chaque refresh. Tu ouvres le profiler Chrome, et tu vois 800 lignes recréées de zéro à chaque tick. Pas réutilisées. Recréées. Avec les composants enfants détruits puis re-instanciés, les inputs binding ré-évalués, les directives ré-attachées, les animations qui repartent du début, et les <input> qui perdent leur focus.

Le coupable est presque toujours le même : l'expression que tu as collée derrière track dans ton @for. Angular l'utilise pour décider, à chaque cycle de change detection, quelles lignes du DOM correspondent à quels items. Tu te trompes là-dessus, et tu signes pour un re-render complet à chaque fois que ton signal change. Même si une seule ligne a bougé.

Voici les 5 erreurs qui reviennent dans toutes les revues de code, avec à chaque fois le before/after et la règle pour ne plus jamais les commettre.

Valide Angular 17+ (introduction de @for). Angular 18+ peut signaler les duplicate keys au runtime via NG0955.


TL;DR

Erreur Symptôme Correctif
track $index sur liste réordonnée Focus perdu, state interne décalé track item.id
track item sur HTTP refresh Tout recréé alors que les données sont identiques track item.id
ID instable (Math.random, crypto.uuid()) Rebuild complet à chaque map / fetch ID stable côté backend ou clé business
Expression composite dans track Allocation string par item, par tick Méthode pure ou clé déjà composée
Clés dupliquées Warning NG0955 (dev) ou rendu imprévisible Garantir l'unicité avant le render

Règle : track doit donner une identité stable à chaque item, pas un index dans l'array et pas une référence d'objet recréée.


Comment Angular utilise track (le rappel de 30 secondes)

Quand un signal lu dans le template change, Angular re-évalue ton @for. Pour chaque item de la nouvelle collection, il calcule la valeur de track, la compare aux valeurs du tick précédent, et décide :

  • même clé déjà présente → la ligne est réutilisée, seuls les bindings sont mis à jour.
  • nouvelle clé → la ligne est créée.
  • clé absente de la nouvelle collection → la ligne est détruite.

Sans track fiable, Angular n'a aucun moyen de savoir que l'item à l'index 3 du nouvel array est le même que celui à l'index 2 du précédent. Il jette tout et recommence. Et ce sont tes composants enfants, tes directives, tes ngModel, tes états locaux qui en paient le prix.


Erreur 1 : track $index sur une liste qui peut être réordonnée

C'est l'héritage de *ngFor sans trackBy. Tu copies-colles, le compilateur exige une expression, tu mets $index parce que ça marche.

Tant que la liste n'est jamais réordonnée, jamais filtrée, jamais préfixée d'un nouvel élément, ça passe. Le jour où tu ajoutes un tri client, ou une suppression au milieu, ou un insert en tête, le bug apparaît : les composants enfants gardent leur état mais affichent les données du voisin.

Le cas qui révèle le bug

import { ChangeDetectionStrategy, Component, signal } from '@angular/core';

type Task = { id: string; label: string };

@Component({
  selector: 'app-task-list',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @for (task of tasks(); track $index) {
      <label>
        <input type="checkbox" />
        {{ task.label }}
      </label>
    }
    <button (click)="moveFirstToEnd()">Déplacer la 1ère en bas</button>
  `,
})
export class TaskList {
  protected readonly tasks = signal<Task[]>([
    { id: 'a', label: 'Écrire la spec' },
    { id: 'b', label: 'Implémenter' },
    { id: 'c', label: 'Tester' },
  ]);

  protected moveFirstToEnd(): void {
    this.tasks.update(([first, ...rest]) => [...rest, first]);
  }
}

Coche "Écrire la spec", puis clique sur le bouton : la case cochée reste sur la première ligne du DOM. Sauf que la première ligne du DOM, c'est maintenant "Implémenter", pas "Écrire la spec". Angular a réutilisé les <input> par position, et a juste réécrit le label. La coche, elle, vit dans le DOM : Angular ne la re-binde pas, elle reste plantée à l'index 0.

C'est le point clé pour comprendre ce bug : tout ce qu'Angular re-binde (un [checked]="task.done" par exemple) suit les données, donc reste correct. Ce qui se décale, c'est tout ce qu'Angular ne suit pas : une coche posée par l'utilisateur, le texte tapé dans un input, le focus, l'état local d'un composant enfant.

Après

@for (task of tasks(); track task.id) {
  <label>
    <input type="checkbox" />
    {{ task.label }}
  </label>
}

Avec track task.id, Angular suit chaque ligne par son identité. Le <input> coché part avec sa task, à sa nouvelle position. Pas de surprise.

Règle : track $index est correct uniquement si tu peux jurer que l'ordre des items ne change jamais ET qu'aucun item n'est ajouté en milieu d'array. Toute liste affichée à partir d'un HTTP/store sort de ce périmètre.


Erreur 2 : track item sur des objets recréés à chaque fetch

track item (la référence d'objet) paraît parfait : Angular compare par identité, deux ticks consécutifs avec le même objet réutilisent la ligne. Sauf que dans une app moderne, les objets sont rarement identiques d'un tick à l'autre.

Dès que tu fais un http.get<Item[]>(), tu reçois un nouvel array, avec de nouveaux objets, à chaque appel. Même si le backend renvoie le même JSON. JavaScript ne déduplique pas. { id: 1 } !== { id: 1 }.

Pareil avec un computed() qui map ou filter :

protected readonly visibleItems = computed(() =>
  this.items().map((item) => ({ ...item, displayName: item.name.toUpperCase() })),
);

À chaque tick où items() change, visibleItems() renvoie des objets neufs. track item invalide tout.

Avant

@Component({
  selector: 'app-product-list',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @for (product of products(); track product) {
      <app-product-card [product]="product" />
    }
  `,
})
export class ProductList {
  protected readonly products = toSignal(
    inject(ProductsApi).list().pipe(map((items) => items.map(normalize))),
    { initialValue: [] },
  );
}

À chaque refetch (polling, retry, refresh après mutation) → 100 % des app-product-card détruites et recréées, animations qui repartent, scroll position dans les cartes perdue.

Après

@for (product of products(); track product.id) {
  <app-product-card [product]="product" />
}

Toutes les cartes dont l'id était déjà présent sont réutilisées. Les références d'objets changent quand même à chaque fetch (le map(normalize) recrée tout), donc [product] est rebindé sur toutes les cartes ; mais rebinder un input coûte sans commune mesure moins cher que détruire puis recréer le composant, son DOM, ses animations et son scroll.

Règle : tracker par référence d'objet n'a de sens que si tu contrôles la stabilité de la référence (cache mémoïsé, store qui fige les entités). Sinon, prends un id business.


Erreur 3 : un id pas vraiment stable

Tu mets track item.id, donc tu es tranquille. Sauf si ton id est généré côté front au moment du map :

protected readonly notifications = computed(() =>
  this.raw().map((n) => ({ ...n, id: crypto.randomUUID() })),
);

À chaque tick, nouvel UUID, nouvelle clé pour track. Angular considère que tous les items sont nouveaux. Tout est détruit, tout est recréé. Tu as exactement le même résultat qu'avec track $index sur une liste réordonnée, sauf que là c'est à chaque tick.

Variante plus subtile :

protected readonly rows = computed(() =>
  this.items().map((it, i) => ({ ...it, rowKey: `${it.category}-${i}` })),
);

rowKey n'est stable que tant que la liste n'est ni filtrée ni triée. Dès qu'un filtre coupe trois items, tous les i glissent, les rowKey changent, rebuild complet.

La règle

Un id de tracking doit être :

  1. Calculé une fois, à la source (backend, store, génération à l'insertion).
  2. Invariant par rapport à l'ordre et au filtrage côté UI.
  3. Unique sur l'ensemble de la collection qui passe dans le @for.

Si ton backend ne te donne pas d'id, génère-le à l'insertion dans ton store ou ta collection, pas à la dérivation :

protected readonly items = signal<Item[]>([]);

protected addItem(input: Omit<Item, 'id'>): void {
  this.items.update((current) => [...current, { ...input, id: crypto.randomUUID() }]);
}

Là, id est posé une fois et ne bouge plus.


Erreur 4 : une expression composite dans track

Tu n'as pas un id unique, mais une paire de champs qui forme une clé. Tentation classique :

@for (cell of cells(); track cell.row + '-' + cell.column) {
  <app-cell [data]="cell" />
}

Ça marche. Ça donne une clé stable et unique. Mais l'expression est ré-évaluée à chaque tick, pour chaque item. Sur une grille 100x100, c'est 10 000 concaténations de strings et 10 000 allocations par tick. Sur un appareil bas de gamme, ça se voit dans les frame times.

Mieux : pré-calculer la clé à la source

type Cell = { row: number; column: number; value: number; key: string };

protected readonly cells = computed<Cell[]>(() =>
  this.raw().map((cell) => ({ ...cell, key: `${cell.row}-${cell.column}` })),
);
@for (cell of cells(); track cell.key) {
  <app-cell [data]="cell" />
}

La concaténation a lieu une fois par cell, dans le computed() mémoïsé. Le track lit juste une propriété déjà allouée. Et bonus : si tu pousses la génération de key côté backend ou côté store, le computed() lui-même disparaît.

Règle : track doit être une lecture, pas un calcul. Si tu as besoin d'une clé composée, calcule-la à l'insertion et lis-la dans le template.


Erreur 5 : des clés dupliquées qui passent silencieusement

track n'est pas un Set. Angular ne déduplique rien. Si deux items renvoient la même clé, le comportement est indéfini : en mode dev, Angular 18+ te le signale avec un warning NG0955 dans la console. En prod, rien. Tu récupères juste un rendu imprévisible : ligne rendue en double, item qui disparaît, et personne pour te dire pourquoi.

Le cas typique : ta liste fusionne deux sources qui utilisent leur propre séquence d'id.

type Notification = { id: number; source: 'email' | 'push'; body: string };

protected readonly merged = computed<Notification[]>(() => [
  ...this.emailNotifs(),
  ...this.pushNotifs(),
]);

emailNotifs() envoie id: 1, 2, 3..., pushNotifs() aussi. Tu te retrouves avec deux items dont id === 1. track item.id est en collision.

Avant

@for (notif of merged(); track notif.id) {
  <app-notif [notif]="notif" />
}

Après

Soit tu composes une clé qui inclut la source (et tu pré-calcules, cf. erreur 4) :

type KeyedNotification = Notification & { key: string };

protected readonly merged = computed<KeyedNotification[]>(() => [
  ...this.emailNotifs().map((n) => ({ ...n, key: `email-${n.id}` })),
  ...this.pushNotifs().map((n) => ({ ...n, key: `push-${n.id}` })),
]);
@for (notif of merged(); track notif.key) {
  <app-notif [notif]="notif" />
}

Soit tu remontes le namespacing au plus tôt, dès l'adapter qui parse la réponse. Plus tu décales le compose vers la source, plus le reste du code reste simple.

Règle : avant de poser un track, demande-toi si ta collection peut, un jour, contenir deux items qui renvoient la même clé. Si la réponse n'est pas un "non" formel, tu dois composer.


Quand track $index est-il acceptable ?

Rarement, mais pas jamais. Le cas légitime existe et il est précis : ta collection est une liste figée de primitives (string, number) rendue de façon purement présentationnelle, sans composant enfant à état, sans focus possible, sans animation à préserver.

@Component({
  selector: 'app-stars',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @for (_ of stars(); track $index) {
      <span aria-hidden="true">★</span>
    }
  `,
})
export class Stars {
  readonly count = input.required<number>();
  protected readonly stars = computed(() => Array.from({ length: this.count() }));
}

Aucune des lignes ne porte d'état. Si la valeur change, tout peut être recréé sans conséquence. track $index est même légèrement plus rapide ici, parce qu'il évite à Angular de hasher l'item.

Hors de ce cas, considère que track $index est une dette technique en attente.


Récap actionnable

  1. Par défaut, track item.id. Si tu n'as pas d'id stable côté serveur, génère-le à l'insertion dans ton store, pas dans un computed().
  2. Évite track item sauf si tu contrôles la stabilité de la référence (store immutable, cache mémoïsé). Tout http.get invalide ce contrat.
  3. Réserve track $index aux listes figées de primitives sans état enfant.
  4. Une clé composite se calcule en amont, pas dans l'expression de track. Le template doit lire, pas concaténer.
  5. Garantis l'unicité au moment où tu agrèges plusieurs sources. Sinon, namespace la clé.

Pour vérifier que tu n'as pas régressé : ouvre le Performance profiler de Chrome, déclenche le scénario qui rebuild ta liste, et regarde la timeline des "Recalculate style" et "Layout". Si tu vois 800 lignes recréées pour 800 lignes affichées, c'est ton track qui parle. Corrige-le, refais la mesure, et regarde le score d'invalidation chuter à zéro.

📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

AngularKit

Suite d'outils pour développeurs Angular francophones. Apprends, modernise tes réflexes, audite ta codebase.

Produits

Contact

Légal

© 2026 AngularKit. Tous droits réservés.