~10 min de lecture

Angular Route.providers : ce qu'ils ne font PAS par défaut (et comment forcer un vrai reset de ton cache de feature)

Tu ouvres la feature "Wizard de facturation". Elle instancie WizardState. Tu le scopes proprement via Route.providers sur la route parente, pas de providedIn: 'root'. Tu te dis "c'est propre, il vivra le temps de la feature".

Tu quittes. Retour au dashboard, sans avoir rechargé la page. Vingt minutes plus tard, tu rouvres le wizard. Le formulaire est encore pré-rempli avec l'email que tu avais saisi.

Tu ouvres les DevTools, tu inspectes : c'est la même instance de WizardState. Le state n'a jamais été reset. Route.providers ne fait pas ce que tu croyais qu'il faisait.

Valide Angular 14.0+ pour Route.providers (introduit dans la même release que loadComponent). Le flag withExperimentalAutoCleanupInjectors() mentionné plus bas est expérimental depuis Angular 21.1. Les exemples utilisent les APIs modernes : signal(), inject(), @Injectable.


Ce que Route.providers fait vraiment par défaut

Quand une Route déclare un tableau providers: [], le router crée un EnvironmentInjector associé à cette route (l'injector "d'environnement" qu'Angular utilise pour les providers de niveau route ou application, par opposition au NodeInjector d'un composant). Cet injector fait trois choses, dans cet ordre d'importance :

  1. Il scope la visibilité du token. Le service n'est résolvable qu'à partir des descendants de la route. Un composant qui n'a pas la route dans ses ancêtres reçoit NG0201: No provider found.
  2. Il partage une instance dans son sous-arbre. Tous les composants sous la route reçoivent la même instance, sans passer par un singleton providedIn: 'root'.
  3. Il permet de surcharger un token existant. Un InjectionToken déclaré avec providedIn: 'root' peut être remplacé sur un sous-arbre de routes via un provider explicite.

Ce qu'il ne fait pas par défaut : détruire l'injector à la désactivation. Contrairement à ce que beaucoup d'articles laissent entendre (y compris notre article sur la restauration d'état au retour arrière, corrigé en même temps que celui-ci), quitter la route ne libère pas l'instance. L'injector reste vivant, ton state persiste, et si tu re-navigues vers la même route sans avoir rechargé l'app, tu récupères la même instance dans le même état.

Deux mécanismes permettent de forcer un vrai reset, détaillés plus bas dans la section sur l'état éphémère.


Pattern 1 : encapsulation / scoping (le vrai bénéfice par défaut)

Le premier bénéfice, souvent sous-estimé, est de cacher un service à des features qui ne devraient pas y accéder. providedIn: 'root' te force à faire confiance au reste de la codebase pour ne pas injecter n'importe où. Avec Route.providers, l'accès est mécaniquement empêché.

Avant

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

@Injectable({ providedIn: 'root' })
export class WizardState {
  private readonly _step1 = signal<Step1Data | null>(null);
  readonly step1 = this._step1.asReadonly();
  setStep1(data: Step1Data): void { this._step1.set(data); }
}

type Step1Data = { name: string; email: string };

N'importe quel composant de l'app peut inject(WizardState). Rien ne t'empêche d'écrire du code qui dépend du wizard depuis le header, ou depuis un composant d'admin qui n'a rien à faire avec la facturation. Le couplage s'installe sans que le compilateur bronche.

Après

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

@Injectable()
export class WizardState {
  private readonly _step1 = signal<Step1Data | null>(null);
  readonly step1 = this._step1.asReadonly();
  setStep1(data: Step1Data): void { this._step1.set(data); }
}

type Step1Data = { name: string; email: string };
import { Routes } from '@angular/router';
import { WizardState } from './wizard-state';

export const WIZARD_ROUTES: Routes = [
  {
    path: '',
    providers: [WizardState],
    loadComponent: () => import('./wizard-shell').then((m) => m.WizardShell),
    children: [
      { path: '', redirectTo: 'step-1', pathMatch: 'full' },
      { path: 'step-1', loadComponent: () => import('./steps/step-1').then((m) => m.Step1) },
      { path: 'step-2', loadComponent: () => import('./steps/step-2').then((m) => m.Step2) },
      { path: 'step-3', loadComponent: () => import('./steps/step-3').then((m) => m.Step3) },
    ],
  },
];

Les trois étapes appellent inject(WizardState) normalement. Elles reçoivent toutes la même instance, résolue depuis l'injector de la route parente.

Un composant hors du sous-arbre /wizard qui tente d'injecter WizardState reçoit NG0201: No provider found : la fuite d'accès devient un échec de résolution DI, pas un problème silencieux de couplage.

Attention : ce pattern ne reset pas le state entre deux entrées dans la feature. Si tu comptes sur Route.providers pour ça, saute directement au cas particulier de l'état éphémère, plus bas.


Pattern 2 : surcharge d'un token global (thème)

Ce cas d'usage marche pleinement par défaut, sans cleanup nécessaire. Tu as un PALETTE global qui expose la palette courante. Sur une feature "Marketing" en marque blanche, tu veux forcer une palette client sans toucher au reste de l'app.

import { InjectionToken } from '@angular/core';

export type Palette = { primary: string; accent: string };

export const PALETTE = new InjectionToken<Palette>('PALETTE', {
  providedIn: 'root',
  factory: () => ({ primary: '#00204A', accent: '#F9ED32' }),
});
export const MARKETING_ROUTES: Routes = [
  {
    path: '',
    providers: [
      { provide: PALETTE, useValue: { primary: '#8B0000', accent: '#F5DEB3' } },
    ],
    loadComponent: () => import('./marketing-shell').then((m) => m.MarketingShell),
    children: [/* ... */],
  },
];

Tout composant qui appelle inject(PALETTE) sous /marketing reçoit la palette surchargée. Ailleurs, la palette globale reste inchangée. Pas de flag isMarketingContext, pas de if dans un service.

Ce pattern est stable depuis Angular 14.0 et ne dépend d'aucun opt-in. Il te débarrasse d'une bonne partie des services "à comportement conditionnel" qu'on garde en providedIn: 'root' par défaut.


Pattern 3 : partager un service entre routes sœurs via leur parent commun

Corollaire des deux précédents. Tu as /orders/list et /orders/detail/:id. Elles ont besoin de partager un OrdersCache. Si tu déclares le cache sur /orders/list et /orders/detail/:id séparément, tu obtiens deux EnvironmentInjector distincts et deux instances.

Provisionne sur la route parente commune :

export const ORDERS_ROUTES: Routes = [
  {
    path: '',
    providers: [OrdersCache],
    loadComponent: () => import('./orders-shell').then((m) => m.OrdersShell),
    children: [
      { path: '', loadComponent: () => import('./list').then((m) => m.OrdersList) },
      { path: 'detail/:id', loadComponent: () => import('./detail').then((m) => m.OrdersDetail) },
    ],
  },
];

Une seule instance de OrdersCache pour tout le sous-arbre /orders, partagée entre list et detail. Sans cleanup opt-in, elle survit également à une sortie de /orders : re-entrer dans la feature te ramène le cache dans l'état où tu l'as laissé.


Cas particulier : état éphémère, deux façons de forcer un reset

Retour au wizard de l'intro : tu veux que quitter /wizard remette le state à zéro pour repartir propre. Idem pour un cache multi-tenant, où tu veux libérer les métriques du tenant courant à la sortie de /analytics.

Route.providers seul, tel qu'utilisé dans les trois patterns précédents, ne fait pas ça : sans rien de plus, l'instance survit pour toute la session applicative. Deux voies permettent d'obtenir un vrai reset.

Option A : Component.providers sur le composant shell de la route

Reprends la structure du Pattern 1, en déplaçant le tableau providers: [WizardState] de la Route vers le décorateur @Component du shell qui porte le <router-outlet /> de la feature :

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

@Injectable()
export class WizardState {
  private readonly _step1 = signal<Step1Data | null>(null);
  readonly step1 = this._step1.asReadonly();
  setStep1(data: Step1Data): void { this._step1.set(data); }
}

type Step1Data = { name: string; email: string };

@Component({
  selector: 'app-wizard-shell',
  template: `<router-outlet />`,
  imports: [RouterOutlet],
  providers: [WizardState],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class WizardShell {}
export const WIZARD_ROUTES: Routes = [
  {
    path: '',
    loadComponent: () => import('./wizard-shell').then((m) => m.WizardShell),
    children: [
      { path: '', redirectTo: 'step-1', pathMatch: 'full' },
      { path: 'step-1', loadComponent: () => import('./steps/step-1').then((m) => m.Step1) },
      { path: 'step-2', loadComponent: () => import('./steps/step-2').then((m) => m.Step2) },
      { path: 'step-3', loadComponent: () => import('./steps/step-3').then((m) => m.Step3) },
    ],
  },
];

Step1, Step2 et Step3 restent descendants de WizardShell, donc inject(WizardState) résout toujours la même instance. Ce qui change, c'est la durée de vie : entre step-1 et step-2, WizardShell reste monté (même routeConfig), donc le state survit. Mais dès que tu quittes /wizard entièrement, le router retire WizardShell du DOM, ce qui déclenche son ngOnDestroy() standard et libère WizardState avec lui. Aucun flag requis : Component.providers s'est toujours comporté comme ça.

Le compromis : tes providers quittent la Route pour vivre sur un composant applicatif, ce qui pèse si plusieurs configurations de routes doivent partager le même provider sans dupliquer de shell.

Option B : le flag withExperimentalAutoCleanupInjectors() (v21.1+)

Si tu préfères garder tes providers sur Route.providers tel quel, Angular 21.1 a introduit une feature router expérimentale pour ça :

import { ApplicationConfig } from '@angular/core';
import {
  provideRouter,
  withExperimentalAutoCleanupInjectors,
} from '@angular/router';
import { appRoutes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(
      appRoutes,
      withExperimentalAutoCleanupInjectors(),
    ),
  ],
};

Avec ce flag actif, à chaque NavigationEnd, le router balaie la config de routes et détruit l'EnvironmentInjector de toute route qui n'est plus dans la chaîne active, à condition que la RouteReuseStrategy en cours (la classe qui décide si une route est réutilisée ou reconstruite au lieu d'être rejouée à neuf) renvoie true pour cette route à sa méthode shouldDestroyInjector(). La DefaultRouteReuseStrategy utilisée par défaut (qui étend BaseRouteReuseStrategy) renvoie déjà true : si tu n'as pas remplacé la stratégie par défaut, activer le flag suffit. Le comportement de destruction devient :

  • Les services de la route reçoivent ngOnDestroy() et les callbacks DestroyRef.onDestroy().
  • L'instance est éligible au GC, la prochaine activation en recrée une nouvelle avec un state vierge.
  • Une navigation annulée, en erreur, ou interrompue par un guard ne déclenche aucun nettoyage : seul un NavigationEnd qui aboutit le fait.

Piège si tu as une RouteReuseStrategy custom (par exemple celle de notre article sur la restauration d'état au retour arrière) : shouldDestroyInjector() n'est déclarée que sur BaseRouteReuseStrategy, pas sur l'interface RouteReuseStrategy. Une classe qui écrit implements RouteReuseStrategy sans étendre BaseRouteReuseStrategy compile sans cette méthode, et le flag devient un no-op silencieux : zéro destruction, aucune erreur. Vérifie que ta stratégie custom l'expose avant de compter sur ce flag.

Statut à connaître : la feature est marquée expérimentale (@experimental 21.1). Le nom peut évoluer, l'API peut changer.

Aucune option n'est strictement supérieure : Component.providers marche partout sans opt-in mais déplace tes providers hors de Route.providers ; le flag les y garde, au prix du statut expérimental et de la dépendance à ta RouteReuseStrategy. En dessous de 21.1 sans restructurer, reste la solution manuelle : un reset() côté service, appelé dans un guard canDeactivate.


Piège 1 : Route.providers vs Component.providers

Les deux existent, mais ils ne se comportent pas pareil.

  • Route.providers : crée un EnvironmentInjector associé à la route. Instance unique et partagée dans tout le sous-arbre routé. Sans rien de plus, elle survit à la désactivation de la route pour toute la session applicative.
  • Component.providers : crée un injector au niveau du composant. Une instance par composant instancié : autant d'itérations d'un @for qui rend ce composant, autant d'instances distinctes du service. Détruit automatiquement quand le composant est retiré du DOM, sur toutes les versions d'Angular.

Règle d'or : la granularité par défaut de Route.providers seul est "une instance pour toute la session applicative", pas "une instance par activation". Pour une vraie granularité "par activation", passe par Component.providers sur le shell ou par le flag v21.1+ (section "état éphémère" plus haut).


Piège 2 : runGuardsAndResolvers et une RouteReuseStrategy par défaut ne suffisent pas à un reset garanti

Une confusion fréquente : "si je désactive la RouteReuseStrategy par défaut ou si je force runGuardsAndResolvers: 'always', alors la route est reconstruite à chaque entrée, donc le state est reset."

Faux. runGuardsAndResolvers gouverne le rejeu des guards et des resolvers, pas la destruction de l'EnvironmentInjector. Et la RouteReuseStrategy par défaut ne détache pas les routes (donc rien n'est mis en cache), mais elle ne détruit pas non plus les injectors qu'elle a créés.

Pour un reset garanti sans recharger l'app, revois la section "état éphémère" : Component.providers sur le shell, le flag v21.1+, ou à défaut un reset() manuel côté service.


Piège 3 : TestBed.inject ne voit pas un service scopé à une route

C'est le piège qui fait crasher les premiers tests qu'on écrit sur Route.providers. On veut vérifier qu'un composant sous la route reçoit bien le service, on écrit :

// ❌ Ne fonctionne pas
await router.navigate(['/wizard/step-1']);
const state = TestBed.inject(WizardState); // NG0201: No provider found

TestBed.inject interroge l'injector racine du TestBed. WizardState vit dans l'EnvironmentInjector créé par la route, pas dans celui-là, et l'injector racine n'a jamais la route dans ses ancêtres. La résolution échoue.

Pour récupérer l'instance réelle, il faut passer par un composant réellement instancié sous la route :

import { ChangeDetectionStrategy, Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideRouter, Router, RouterOutlet } from '@angular/router';
import { WIZARD_ROUTES } from './wizard.routes';
import { WizardState } from './wizard-state';
import { Step1 } from './steps/step-1';

@Component({
  template: `<router-outlet />`,
  imports: [RouterOutlet],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
class TestHost {}

it('résout WizardState depuis un composant sous la route', async () => {
  TestBed.configureTestingModule({
    providers: [
      provideRouter([
        { path: 'wizard', children: WIZARD_ROUTES },
      ]),
    ],
  });

  const fixture = TestBed.createComponent(TestHost);
  const router = TestBed.inject(Router);

  await router.navigate(['/wizard/step-1']);
  await fixture.whenStable();
  fixture.detectChanges();

  const stepDebugEl = fixture.debugElement.query((el) => el.componentInstance instanceof Step1);
  const state = stepDebugEl.injector.get(WizardState);
  expect(state).toBeInstanceOf(WizardState);
});

Le debugElement.injector interroge la chaîne d'injectors depuis le composant ; cette chaîne remonte jusqu'à l'injector de la route et donc au WizardState. C'est le pattern à retenir pour tout test qui touche à un service scopé à une route.


Récap actionnable

  • Route.providers fait par défaut trois choses : il scope la visibilité du token, il partage une instance dans le sous-arbre, il permet de surcharger un token global. Il ne détruit pas l'injector à la désactivation : sans rien de plus, l'instance survit pour toute la session applicative.
  • Trois patterns qui gagnent sans rien de plus : cacher un service à une feature (encapsulation), surcharger un token global sur un sous-arbre (thème), et partager une instance entre routes sœurs via leur parent commun (cache orders).
  • Pour l'état éphémère (wizard, cache multi-tenant), deux voies sans reload manuel : Component.providers sur le shell de la route (toutes versions, aucun opt-in), ou le flag withExperimentalAutoCleanupInjectors() en v21.1+ (expérimental, dépendant d'une RouteReuseStrategy qui implémente shouldDestroyInjector()). En dessous de 21.1 sans restructurer, câble un reset() explicite dans un canDeactivate.
  • Trois pièges : ne confonds pas la durée de vie par défaut de Route.providers seul (toute la session applicative) avec une durée de vie par activation ; runGuardsAndResolvers: 'always' ne détruit rien ; TestBed.inject ne voit pas un service scopé à une route, il faut passer par le debugElement.injector d'un composant réellement rendu sous la route.
  • Le check à faire sur ton projet : grep -rn "providedIn: 'root'" src/. Pour chaque service, pose deux questions distinctes. Est-ce qu'il a besoin d'être visible partout ? Si non, Route.providers le scope. Est-ce qu'il a besoin d'être détruit à la sortie de feature ? Si oui, passe par Component.providers sur le shell ou par le flag v21.1+. Ce sont deux décisions séparées, pas une seule.

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