~10 min de lecture

Le toast qui disparaît trop tôt : ton setTimeout te trahit, le système complet tient en 90 lignes de signals

TL;DR

La version maison classique d'un toast (une propriété message, un setTimeout qui n'est pas annulé quand le message change et qui efface sans vérifier ce qu'il efface) porte un bug de timer périmé : le timeout du premier toast ferme le deuxième, avant la fin de sa durée d'affichage. La correction structurelle tient en deux décisions : les toasts sont une liste dans un signal, pas un message unique, et chaque timer est rangé sous l'id du toast qu'il doit fermer, annulé à la fermeture manuelle. Le tout, service + composant, tient en 90 lignes sans dépendance, fonctionne en zoneless, et le conteneur aria-live rend les toasts audibles aux lecteurs d'écran.

Vendredi, 16h40. Un testeur te remonte un comportement bizarre : "le message d'erreur s'affiche puis disparaît presque tout de suite, j'ai pas le temps de le lire". Tu testes. Effectivement : quand une sauvegarde réussit puis qu'une erreur réseau tombe quatre secondes plus tard, le toast d'erreur s'évapore au bout d'une seconde. Seul, il reste bien ses cinq secondes.

Ce bug n'est pas une anecdote. Dès qu'un nouveau message peut remplacer l'ancien avant l'expiration de son setTimeout - un timer que rien n'annule et qui efface sans vérifier ce qu'il efface - le bug est là, tellement prévisible qu'on peut l'écrire d'avance. Regardons-le, puis construisons la version qui ne peut pas l'avoir.

La version maison qui te trahit

Voilà le code qu'on trouve dans beaucoup de projets :

// notification.service.ts - la version naïve
import { Injectable } from '@angular/core';
import { Subject } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class NotificationService {
  readonly message$ = new Subject<string>();

  notify(message: string): void {
    this.message$.next(message);
  }
}
// notification-banner.ts - la version naïve
import {
  ChangeDetectionStrategy,
  Component,
  inject,
  signal,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NotificationService } from './notification.service';

@Component({
  selector: 'app-notification-banner',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @if (message()) {
      <div class="banner">{{ message() }}</div>
    }
  `,
})
export class NotificationBanner {
  protected readonly message = signal<string | null>(null);

  constructor() {
    inject(NotificationService)
      .message$.pipe(takeUntilDestroyed())
      .subscribe((message) => {
        this.message.set(message);
        setTimeout(() => this.message.set(null), 5000);
      });
  }
}

Déroule la timeline du bug remonté par ton testeur :

  1. t = 0s : notify('Sauvegardé'). Le message s'affiche, un timeout A est armé pour t = 5s.
  2. t = 4s : notify('Erreur réseau'). Le message est remplacé, un timeout B est armé pour t = 9s.
  3. t = 5s : le timeout A se déclenche et met message à null. Il efface l'erreur réseau, affichée depuis une seconde.

Le timeout A est périmé : il a été armé pour fermer un message qui n'existe plus, et personne ne l'a annulé. C'est la même famille de bug que la réponse HTTP périmée qui écrase une réponse plus récente, mais côté timer.

Et ce n'est que le défaut qu'on t'a remonté. Il y en a deux autres :

  • Un seul message à la fois. La sauvegarde réussie de l'étape 1 a disparu de l'écran à l'étape 2 sans avoir vécu ses cinq secondes. Deux événements rapprochés, un seul affiché.
  • Rien n'est annoncé aux lecteurs d'écran. Un div qui apparaît dans le DOM est un changement visuel, pas une annonce. Sans région aria-live (une zone du DOM dont le lecteur d'écran annonce les ajouts), un utilisateur de lecteur d'écran ne saura jamais que sa sauvegarde a réussi.

Le réflexe à ce stade, c'est souvent npm install d'une lib de toasts. Défendable, mais pour un besoin aussi borné, tu peux avoir mieux : un système complet, à ta main, sans dépendance à surveiller à chaque montée d'Angular.

Un signal, une liste, des timers tenus en laisse

Le timer périmé et le message unique se corrigent par deux décisions :

  1. L'état n'est pas un message mais une liste de toasts, chacun avec un id. Deux événements rapprochés coexistent à l'écran au lieu de s'écraser.
  2. Chaque timer est enregistré sous l'id du toast qu'il ferme. Fermer un toast à la main annule son timer, et un timer ne peut pas fermer autre chose que son toast.
// toast.service.ts
import { Injectable, signal } from '@angular/core';

export type ToastKind = 'success' | 'error' | 'info';

export interface Toast {
  readonly id: number;
  readonly kind: ToastKind;
  readonly message: string;
}

const DEFAULT_DURATION_MS = 5000;

@Injectable({ providedIn: 'root' })
export class ToastService {
  readonly #toasts = signal<readonly Toast[]>([]);
  readonly toasts = this.#toasts.asReadonly();

  #nextId = 0;
  readonly #timers = new Map<number, ReturnType<typeof setTimeout>>();

  show(
    message: string,
    kind: ToastKind = 'info',
    durationMs: number = DEFAULT_DURATION_MS,
  ): number {
    const id = ++this.#nextId;
    this.#toasts.update((toasts) => [...toasts, { id, kind, message }]);

    if (durationMs > 0) {
      this.#timers.set(id, setTimeout(() => this.dismiss(id), durationMs));
    }
    return id;
  }

  dismiss(id: number): void {
    const timer = this.#timers.get(id);
    if (timer !== undefined) {
      clearTimeout(timer);
      this.#timers.delete(id);
    }
    this.#toasts.update((toasts) => toasts.filter((toast) => toast.id !== id));
  }
}

Quatre choix à expliquer :

  • asReadonly() : les consommateurs lisent toasts, personne ne peut faire un .set() depuis l'extérieur. L'écriture passe par show() et dismiss(), qui maintiennent l'invariant "un toast affiché a au plus un timer, et ce timer le vise lui".
  • Une durée de 0 désactive l'auto-fermeture. Pour une erreur, c'est souvent le bon réglage : l'utilisateur la ferme quand il l'a lue. Le if (durationMs > 0) évite d'armer un timer inutile.
  • dismiss() annule le timer et est idempotent : l'appeler deux fois sur le même id revient à l'appeler une fois. Si l'utilisateur ferme le toast à la main avant l'expiration, le timer est annulé dans la foulée, donc il ne peut pas se déclencher ensuite sur un toast déjà parti ; et un dismiss() sur un id inconnu ne retire rien.
  • show() retourne l'id, ce qui permet à l'appelant de fermer son propre toast plus tard. Cas typique : "Envoi en cours..." persistant (show('Envoi en cours...', 'info', 0)), fermé par le code quand la requête aboutit.

Le Toaster : 44 lignes, zéro markForCheck()

Un seul composant, monté une fois dans le shell de l'app, à côté du <router-outlet /> :

// toaster.ts
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ToastService } from './toast.service';

@Component({
  selector: 'app-toaster',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="toaster" aria-live="polite">
      @for (toast of toastService.toasts(); track toast.id) {
        <div class="toast" [attr.data-kind]="toast.kind">
          <p>{{ toast.message }}</p>
          <button type="button" (click)="toastService.dismiss(toast.id)">
            Fermer
          </button>
        </div>
      }
    </div>
  `,
  styles: `
    .toaster {
      position: fixed;
      inset-block-end: 1rem;
      inset-inline-end: 1rem;
      display: grid;
      gap: 0.5rem;
    }
    .toast {
      display: flex;
      align-items: center;
      gap: 0.75rem;
      padding: 0.75rem 1rem;
      border-radius: 0.5rem;
      background: #1f2937;
      color: #f9fafb;
    }
    .toast[data-kind='error'] {
      background: #7f1d1d;
    }
  `,
})
export class Toaster {
  protected readonly toastService = inject(ToastService);
}

Rien d'exotique, mais trois détails comptent :

  • track toast.id : à chaque fermeture, Angular retire le nœud du toast fermé et laisse les autres en place. Avec un track $index, chaque nœud à partir de celui du toast fermé prendrait le texte du toast suivant, et c'est le dernier nœud de la liste qui serait retiré : une animation de sortie jouerait donc sur le mauvais élément.
  • [attr.data-kind] plutôt qu'une classe concaténée : le CSS cible [data-kind='error'], et tu ne te poses pas de question sur la fusion entre classes statiques et classes posées par binding.
  • OnPush + signal suffisent, y compris en zoneless. Quand le timer expire, dismiss() écrit dans le signal, ce qui notifie Angular. Pas de markForCheck(), pas de dépendance à zone.js, qui patche les setTimeout pour déclencher la détection de changement. Si tu migres en zoneless demain, ce code ne bouge pas.

Côté versions : le service et le composant fonctionnent tels quels depuis Angular 19, où les composants sont standalone par défaut ; en 17 et 18, ajoute standalone: true au @Component (le control flow @for et l'API signal, eux, sont là dès la 17). Le zoneless est arrivé en expérimental avec Angular 18 et son provider est stable depuis la 20.2. Et si tu es en 20.2 ou plus, ajoute une animation de sortie avec animate.leave, qui attend la fin de l'animation CSS avant de retirer le nœud : c'est le sujet d'un article dédié.

Un piège de rendu serveur, pour finir. Le service s'importe sans risque en SSR (aucune API navigateur au chargement du module), mais un show() exécuté pendant le rendu serveur (intercepteur d'erreurs HTTP, resolver) fait des dégâts. Pour un toast à fermeture automatique : en zoneless, le toast est sérialisé dans le HTML envoyé au navigateur et son setTimeout tourne dans le process Node ; sous zone.js, rien n'est sérialisé mais le timer en attente retarde la réponse serveur de toute sa durée. Un toast à durée 0, lui, est sérialisé dans les deux régimes. Garde ces appels derrière isPlatformBrowser(), côté appelant : le service reste sans injection, donc testable avec un simple new.

L'accessibilité qui tient dans un attribut bien placé

Le conteneur porte aria-live="polite", et ce placement n'est pas décoratif.

Une région live fonctionne par différence de contenu : le lecteur d'écran observe la région et annonce ce qui s'y ajoute. D'où une règle documentée de longue date : la région doit exister dans le DOM dès le rendu initial, et c'est son contenu qui change. C'est ce que fait ce composant : le div.toaster existe en permanence, les toasts s'y insèrent. Si tu poses aria-live sur le toast lui-même, tu insères une région déjà pleine, et une partie des lecteurs d'écran n'annoncera rien.

Le LiveAnnouncer du CDK repose sur le même principe : il maintient une région live masquée dans le <body> pour y pousser des messages. Le toaster étant lui-même la région, tu n'as pas besoin du CDK ici. Une limite, quand même : tant qu'une modale ouverte par showModal() est à l'écran, le reste du document, toaster compris, est hors jeu pour les lecteurs d'écran (c'est le comportement du <dialog> natif, détaillé dans l'article sur le focus trap des modales). Si ton flux enchaîne modale puis toast, émets le toast après la fermeture de la modale.

Deux réglages à connaître :

  • polite vs assertive : polite attend que le lecteur d'écran ait fini sa phrase en cours, assertive l'interrompt. Le bon défaut est polite ; réserve assertive aux erreurs qui exigent une réaction immédiate, dans une deuxième région dédiée.
  • Le bouton Fermer est un vrai <button> : focusable, activable au clavier, doté d'un nom accessible (son texte, "Fermer") - un span cliquable avec une croix ne l'est pas. Tout ce qui s'insère dans la région est du contenu annonçable, libellé de ce bouton compris : garde-le court. Et si un toast porte un bouton d'action, l'auto-fermeture devient ton ennemie : cinq secondes ne suffisent pas pour atteindre ce bouton au clavier ou au lecteur d'écran. Ouvre ces toasts-là avec une durée de 0.

La preuve par le test

Le bug du timer périmé mérite un test de non-régression. Avec les fake timers de Vitest, une horloge virtuelle que le test avance à la main (l'article sur le debounce en zoneless les détaille), la timeline du testeur se rejoue en trois temps dans le deuxième test :

// toast.service.spec.ts
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ToastService } from './toast.service';

describe('ToastService', () => {
  let service: ToastService;

  beforeEach(() => {
    vi.useFakeTimers();
    service = new ToastService();
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  it('retire le toast après sa durée de vie', () => {
    service.show('Sauvegardé');
    expect(service.toasts()).toHaveLength(1);

    vi.advanceTimersByTime(5000);
    expect(service.toasts()).toHaveLength(0);
  });

  it("l'expiration du premier toast ne ferme pas le deuxième", () => {
    service.show('Sauvegardé');
    vi.advanceTimersByTime(4000);
    service.show('Erreur réseau', 'error');
    expect(service.toasts()).toHaveLength(2);

    vi.advanceTimersByTime(1000);
    expect(service.toasts().map((toast) => toast.message)).toEqual([
      'Erreur réseau',
    ]);
  });

  it('la fermeture manuelle annule le timer du toast', () => {
    const id = service.show('Sauvegardé');
    service.dismiss(id);

    expect(vi.getTimerCount()).toBe(0);
    vi.advanceTimersByTime(5000);
    expect(service.toasts()).toHaveLength(0);
  });

  it("un toast à durée nulle reste affiché jusqu'à la fermeture manuelle", () => {
    service.show('Envoi en cours...', 'info', 0);

    vi.advanceTimersByTime(60_000);
    expect(service.toasts()).toHaveLength(1);
  });
});

Remarque le new ToastService() : l'état et les timers vivent dans un service qui n'injecte rien, donc il se teste sans TestBed. Pas de composant monté, pas de stabilité à attendre : la variante synchrone advanceTimersByTime suffit. Le deuxième test rejoue la timeline du bug de l'intro, et son assertion intermédiaire (toHaveLength(2)) verrouille la décision "liste" ; le troisième vérifie, compteur de timers à l'appui, que la fermeture manuelle annule bien le timer du toast. Si quelqu'un "simplifie" le service un jour en revenant au message unique ou en perdant le clearTimeout, un de ces tests vire au rouge avant le testeur du vendredi.

Avant / après

Version naïve Version signals
Deux événements rapprochés Le second écrase le premier Les deux s'affichent, chacun vit sa durée
Timer périmé Ferme le mauvais message Impossible : un timer est lié à un id
Fermeture manuelle Absente (ou timer orphelin) dismiss() annule le timer
Lecteur d'écran Silence Région aria-live permanente
Testabilité Timers dans le composant, contexte d'injection requis (TestBed en pratique) new ToastService() + fake timers

Récap actionnable

  • Un toast maison qui stocke un message et l'efface par un setTimeout que rien n'annule quand le message change et qui efface sans vérifier ce qu'il efface, porte le bug du timer périmé.
  • Modélise les toasts comme une liste dans un signal exposée en asReadonly(), avec un id par toast.
  • Tiens chaque timer dans une Map par id, annule-le dans dismiss(), et saute l'armement quand durationMs vaut 0 (souvent le bon réglage pour les erreurs, les actions en cours et les toasts avec bouton d'action).
  • Le conteneur du toaster porte aria-live="polite" et existe dès le premier rendu ; les toasts s'insèrent dedans. Pas d'aria-live posé sur le toast lui-même.
  • track toast.id dans le @for, jamais $index, pour que la fermeture retire le bon nœud.
  • Écris le test de non-régression du timer périmé avec les fake timers : c'est le test qui justifie tout le reste.

Moins de 90 lignes de service et de composant, quatre tests, zéro dépendance. Avant ton prochain npm install, déroule la timeline du bug : si tu sais l'écrire, tu sais écrire le système qui ne l'a pas.

📧 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.