📧 Reste informé(e) !

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

S'inscrire gratuitement

~8 min de lecture

Router Angular : les 17 events de navigation et pourquoi ton loader tourne dans le vide

Tu as branché un spinner global sur la navigation. Tu l'affiches sur NavigationStart, tu le caches sur NavigationEnd. Ça marche en démo. Puis un jour un guard refuse l'accès, la navigation est annulée, et ton spinner tourne dans le vide. Pour toujours. L'utilisateur est bloqué sur un écran de chargement qui ne finira jamais, parce que NavigationEnd n'a jamais été émis.

Le problème, c'est que tu raisonnes sur deux events alors que le Router en émet jusqu'à dix-sept par navigation. Et surtout, une navigation ne se termine pas toujours par NavigationEnd. Elle peut être annulée, sautée, ou partir en erreur, chacune avec son propre event terminal. Si tu ne les connais pas, tu écris des bugs de loader, tu rates des cancels silencieux, et tu débugges à l'aveugle.

Voici la séquence complète, ce que chaque event apporte, les trois façons d'interrompre une navigation, et les cas d'erreur. Avec à chaque fois le code qui va bien.

Valide Angular 17+. NavigationSkipped existe depuis Angular 14, NavigationCancellationCode et withNavigationErrorHandler depuis Angular 16, le retour d'un redirect depuis le handler d'erreur depuis Angular 19.


TL;DR

Phase Events émis Ce qu'ils apportent
Démarrage NavigationStart id, url, navigationTrigger
Lazy loading RouteConfigLoadStart / RouteConfigLoadEnd le chunk en cours de chargement
Reconnaissance RoutesRecognized l'URL parsée en RouterStateSnapshot
Guards GuardsCheckStart / ChildActivationStart / ActivationStart / GuardsCheckEnd shouldActivate: boolean
Resolvers ResolveStart / ResolveEnd les données résolues sont prêtes
Activation ActivationEnd / ChildActivationEnd les composants vont s'instancier
Fin un seul parmi NavigationEnd, NavigationCancel, NavigationError, NavigationSkipped l'issue réelle

Règle d'or : une navigation se termine toujours par exactement un event terminal, et ce n'est pas forcément NavigationEnd.


La séquence complète d'une navigation qui réussit

Quand tout se passe bien sur une route lazy avec un guard et un resolver, le Router émet, dans cet ordre :

NavigationStart
RouteConfigLoadStart      // début du chargement du chunk lazy
RouteConfigLoadEnd        // chunk chargé
RoutesRecognized          // l'URL est parsée, le RouterStateSnapshot existe
GuardsCheckStart
ChildActivationStart
ActivationStart
GuardsCheckEnd            // shouldActivate: true
ResolveStart
ResolveEnd               // les data des resolvers sont disponibles
ActivationEnd
ChildActivationEnd
NavigationEnd            // event terminal: succès

Chaque event porte au minimum un id (le numéro de la navigation, incrémenté à chaque fois) et l'url cible. Trois d'entre eux portent une charge utile qui sert vraiment au quotidien :

  • NavigationStart expose navigationTrigger ('imperative', 'popstate' ou 'hashchange'). C'est comme ça que tu distingues un clic dans l'app d'un bouton retour navigateur.
  • RoutesRecognized et tous les events suivants exposent state: RouterStateSnapshot. C'est le premier moment où l'arbre de routes est connu : tu peux lire les params, les data, les segments, avant même que le composant existe.
  • GuardsCheckEnd expose shouldActivate: boolean. Si c'est false, tu sais que la navigation va être annulée juste après.

Le détail qui compte : RouteConfigLoadStart/End n'apparaissent que pour les routes lazy, et seulement au premier passage. Une fois le chunk en cache, ces deux events disparaissent de la séquence. Si tu vois un RouteConfigLoadStart sans RouteConfigLoadEnd, c'est ton chunk qui n'a pas pu se charger. On y revient.


Le loader qui tourne dans le vide : le bug du terminal manquant

Voici le pattern qu'on voit dans 9 codebases sur 10 :

import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { NavigationEnd, NavigationStart, Router } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';

@Component({
  selector: 'app-root',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `@if (loading()) { <app-spinner /> }`,
})
export class App {
  private readonly router = inject(Router);
  protected readonly loading = signal(false);

  constructor() {
    this.router.events.pipe(takeUntilDestroyed()).subscribe((event) => {
      if (event instanceof NavigationStart) this.loading.set(true);
      if (event instanceof NavigationEnd) this.loading.set(false);
    });
  }
}

Le bug : NavigationStart met le spinner à true, mais seul NavigationEnd le remet à false. Or une navigation peut finir par NavigationCancel, NavigationError ou NavigationSkipped. Dans ces trois cas, le spinner reste affiché à vie.

Le correctif tient en un type union qui couvre les quatre issues :

import {
  NavigationCancel,
  NavigationEnd,
  NavigationError,
  NavigationSkipped,
  NavigationStart,
} from '@angular/router';

this.router.events.pipe(takeUntilDestroyed()).subscribe((event) => {
  if (event instanceof NavigationStart) {
    this.loading.set(true);
    return;
  }
  if (
    event instanceof NavigationEnd ||
    event instanceof NavigationCancel ||
    event instanceof NavigationError ||
    event instanceof NavigationSkipped
  ) {
    this.loading.set(false);
  }
});

Retiens la liste des quatre events terminaux comme une seule entité. Dès que tu démarres une action sur NavigationStart, tu dois la fermer sur ces quatre-là, jamais sur NavigationEnd seul.


Ce qui interrompt la navigation : NavigationCancel

Quand le Router renonce volontairement à une navigation, il n'émet ni NavigationEnd ni NavigationError. Il émet NavigationCancel, avec une propriété code (un NavigationCancellationCode) qui te dit exactement pourquoi :

import { NavigationCancel, NavigationCancellationCode } from '@angular/router';

this.router.events.subscribe((event) => {
  if (event instanceof NavigationCancel) {
    switch (event.code) {
      case NavigationCancellationCode.GuardRejected:
        // un guard a retourné false
        break;
      case NavigationCancellationCode.Redirect:
        // un guard a retourné un UrlTree -> une nouvelle nav part juste après
        break;
      case NavigationCancellationCode.SupersededByNewNavigation:
        // une autre navigation a démarré avant la fin de celle-ci
        break;
      case NavigationCancellationCode.NoDataFromResolver:
        // un resolver a complété sans jamais émettre
        break;
    }
  }
});

Les quatre codes correspondent à quatre causes bien distinctes :

  • GuardRejected : ton CanActivateFn ou CanMatchFn a renvoyé false. La nav s'arrête net, l'utilisateur reste sur l'URL actuelle. C'est le cas du spinner bloqué vu plus haut.
  • Redirect : ton guard a renvoyé un UrlTree au lieu d'un booléen. La nav courante est annulée, et une nouvelle navigation vers l'URL du UrlTree démarre immédiatement. Tu vas donc voir deux NavigationStart pour ce que l'utilisateur perçoit comme un seul clic.
  • SupersededByNewNavigation : l'utilisateur a cliqué deux fois, ou un router.navigate() est parti pendant qu'une nav lente était en cours. La première est abandonnée. C'est normal, ne le traite pas comme une erreur.
  • NoDataFromResolver : le piège silencieux, qui mérite sa propre section.

Ce sont les quatre codes que tu rencontreras en pratique. Angular en expose un cinquième depuis les versions récentes, Aborted, pour les navigations stoppées programmatiquement via Navigation.abort() : beaucoup plus rare, mais ne sois pas surpris de le croiser.


Le cancel silencieux : un resolver qui retourne EMPTY

Voici le bug le plus vicieux du Router, parce qu'il ne fait rien. Pas d'erreur, pas de log, pas de page. L'utilisateur clique, et il ne se passe rien.

La cause : un resolver qui complète sans jamais émettre de valeur. Le grand classique, c'est le catchError qui renvoie EMPTY pour "gérer proprement" une erreur HTTP :

import { ResolveFn } from '@angular/router';
import { inject } from '@angular/core';
import { catchError, EMPTY } from 'rxjs';

// Bug : si l'API échoue, le resolver complète sans émettre.
// Le Router annule la navigation avec le code NoDataFromResolver.
export const productResolver: ResolveFn<Product> = (route) => {
  const id = route.paramMap.get('id')!;
  return inject(ProductApi).getById(id).pipe(catchError(() => EMPTY));
};

Quand un observable de resolver complète sans rien émettre, le Router considère qu'il n'a pas les données nécessaires et annule la navigation (NoDataFromResolver). L'utilisateur reste sur sa page actuelle, sans le moindre indice de ce qui s'est passé.

Le correctif : ne jamais avaler l'erreur en silence. Soit tu rediriges vers une page d'erreur, soit tu laisses l'erreur remonter pour qu'elle devienne un NavigationError, soit tu émets une valeur de repli explicite :

import { ResolveFn, Router, RedirectCommand } from '@angular/router';
import { inject } from '@angular/core';
import { catchError, of } from 'rxjs';

export const productResolver: ResolveFn<Product | RedirectCommand> = (route) => {
  const router = inject(Router);
  const id = route.paramMap.get('id')!;

  return inject(ProductApi).getById(id).pipe(
    catchError(() => of(new RedirectCommand(router.parseUrl('/not-found')))),
  );
};

Le RedirectCommand (Angular 18+) émis depuis un resolver redirige proprement au lieu d'annuler dans le vide. Au moins, l'utilisateur atterrit quelque part.


Les cas d'erreur : NavigationError

NavigationError est émis quand quelque chose throw pendant la navigation. Contrairement au cancel qui est volontaire, l'erreur est subie. L'event porte le url, l'id, et surtout l'error qui a été levée. Trois causes reviennent en boucle.

1. Un resolver ou un guard qui throw. Si ton ResolveFn lève une exception synchrone, ou si son observable émet une erreur (au lieu de compléter), tu obtiens un NavigationError et non un cancel. La distinction est nette : observable qui complète vide = cancel, observable qui error = error.

2. Le lazy chunk qui ne charge pas. C'est le cas le plus fréquent en prod. Tu déploies une nouvelle version, le hash des fichiers change, et un utilisateur qui avait l'ancien index.html ouvert depuis ce matin clique sur un lien lazy. Le chunk référencé n'existe plus sur ton CDN. Tu vois alors un RouteConfigLoadStart jamais suivi de RouteConfigLoadEnd, puis un NavigationError avec un ChunkLoadError.

3. Une erreur dans le canMatch ou la résolution de route. Plus rare, mais une exception levée pendant la reconnaissance de routes finit aussi en NavigationError.

Le pattern de production pour gérer ça proprement, c'est withNavigationErrorHandler (Angular 16+), un feature de provideRouter qui centralise le traitement :

import { provideRouter, withNavigationErrorHandler, Router, RedirectCommand } from '@angular/router';
import { inject } from '@angular/core';
import { routes } from './app.routes';

export const appConfig = {
  providers: [
    provideRouter(
      routes,
      withNavigationErrorHandler((event) => {
        const router = inject(Router);

        // Chunk périmé après un déploiement : on force un reload dur sur l'URL cible.
        if (event.error?.name === 'ChunkLoadError') {
          location.assign(event.url);
          return;
        }

        // Tout le reste : page d'erreur, sans casser l'historique.
        return new RedirectCommand(router.parseUrl('/error'));
      }),
    ),
  ],
};

Le retour d'un RedirectCommand depuis le handler est dispo depuis Angular 19. Sur Angular 16 à 18, le handler ne fait que de l'effet de bord (log, redirect manuel via router.navigate), il ne peut pas retourner de redirect.


NavigationSkipped : quand le Router ne fait rien, exprès

Dernier event terminal, souvent ignoré : NavigationSkipped. Le Router l'émet quand il décide qu'il n'y a tout simplement rien à faire. Le cas le plus courant : tu navigues vers l'URL sur laquelle tu es déjà. Par défaut, le Router saute la navigation et émet NavigationSkipped avec le code IgnoredSameUrlNavigation.

C'est pour ça que ton NavigationStart puis NavigationEnd que tu attendais ne viennent jamais quand l'utilisateur reclique sur l'onglet actif. Si tu as besoin de re-déclencher (un bouton "rafraîchir" qui pointe vers la même URL), il faut configurer la stratégie :

provideRouter(routes, withRouterConfig({ onSameUrlNavigation: 'reload' }));

Avec 'reload', le Router rejoue la séquence complète (guards et resolvers compris) au lieu de sauter. Pratique, mais à manier avec précaution si tes resolvers font des appels coûteux.


Débugger sans deviner : withDebugTracing

Quand tu ne comprends pas pourquoi une navigation se comporte mal, arrête de poser des console.log. Active le tracing intégré, qui log chaque event du Router dans la console avec son timing :

import { provideRouter, withDebugTracing } from '@angular/router';

provideRouter(routes, withDebugTracing()); // à retirer avant la prod

Tu vois alors défiler toute la séquence. Un RouteConfigLoadStart orphelin t'indique un problème de chunk. Un GuardsCheckEnd avec shouldActivate: false te pointe le guard fautif. Un NavigationCancel avec NoDataFromResolver te dit que c'est un resolver qui avale ses erreurs. Le diagnostic devient une lecture, plus une enquête.


Récap actionnable

  • Une navigation émet jusqu'à 17 events, mais se termine par exactement un parmi NavigationEnd, NavigationCancel, NavigationError, NavigationSkipped.
  • Loader, analytics, états de transition : tout ce que tu ouvres sur NavigationStart, ferme-le sur ces quatre events terminaux, jamais sur NavigationEnd seul.
  • NavigationCancel.code te dit pourquoi : guard qui refuse, redirect par UrlTree, nav superseded, ou resolver vide.
  • Resolver qui retourne EMPTY = cancel silencieux (NoDataFromResolver). Renvoie un RedirectCommand ou laisse l'erreur remonter, ne l'avale jamais.
  • NavigationError couvre les throws : resolver/guard en erreur, et surtout le ChunkLoadError après déploiement. Centralise avec withNavigationErrorHandler.
  • NavigationSkipped explique pourquoi reclic sur l'URL active ne fait rien. onSameUrlNavigation: 'reload' si tu veux forcer.
  • En cas de doute, withDebugTracing() te donne la séquence réelle en deux minutes.

Le Router Angular ne te cache rien : il t'annonce chaque étape de sa décision. Le jour où tu lis ces events au lieu de les ignorer, tes bugs de navigation arrêtent d'être des mystères.

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