~8 min de lecture
Angular : ton back button refetch la liste et perd les filtres
Tu as une page liste. L'utilisateur applique deux filtres, scrolle jusqu'à la moitié, clique sur un item, arrive sur le détail. Puis il tape "back". Tu sais ce qui se passe :
- Le composant liste a été détruit à la navigation aller.
- Il est recréé au retour.
- Le
HttpClientreprend depuis zéro. - Le scroll saute en haut de page.
- Les filtres retournent à leurs valeurs par défaut.
L'utilisateur, lui, ne comprend rien. Ce n'est pas un F5, c'est le back button de son navigateur. Pour lui, il vient juste d'ouvrir un produit et de le refermer.
Le réflexe classique quand on cherche à corriger ça, c'est RouteReuseStrategy. C'est aussi le pire dans la plupart des cas. Cette API existe depuis Angular 4, n'a pas bougé, son ergonomie est franchement hostile, ses pièges silencieux, et dans 8 cas sur 10 tu n'en as tout simplement pas besoin.
Ce guide te donne les 3 vraies stratégies pour ne rien perdre entre deux navigations, dans l'ordre où tu devrais les essayer.
Valide Angular 17+. Les patterns basés sur
withComponentInputBinding()demandent Angular 16+ (bindings surinput()signal depuis Angular 17.1).
Le problème en démo
Une page produits classique. Filtre par catégorie, chargement via HttpClient.
import { Component, ChangeDetectionStrategy, inject, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';
import { RouterLink } from '@angular/router';
type Product = { id: string; name: string; category: string; price: number };
@Component({
selector: 'app-products',
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<select [value]="category()" (change)="category.set($any($event.target).value)">
<option value="">Toutes</option>
<option value="shoes">Chaussures</option>
<option value="bags">Sacs</option>
</select>
@for (product of filtered(); track product.id) {
<a [routerLink]="['/products', product.id]">{{ product.name }}</a>
}
`,
})
export default class Products {
private readonly http = inject(HttpClient);
protected readonly category = signal<string>('');
private readonly products = toSignal(
this.http.get<Product[]>('/api/products'),
{ initialValue: [] },
);
protected readonly filtered = computed(() =>
this.category()
? this.products().filter(p => p.category === this.category())
: this.products(),
);
}
Chaque navigate(['/products']) détruit Products, relance le GET /api/products, remet category à ''. Trois écueils, trois solutions à envisager dans cet ordre.
Stratégie 1 - L'URL comme source de vérité (le bon défaut)
Ta liste a des filtres, un tri, une page ? Ils doivent vivre dans l'URL. Pas dans un signal local, pas dans un service, pas dans localStorage. Dans l'URL.
Trois raisons brutes :
- Le back button du navigateur devient ton pote. L'URL change à chaque interaction, l'historique se remplit,
history.back()restaure la vue sans effort. - Un lien copié transporte la vue exacte. Ton utilisateur envoie un Slack, l'autre bout ouvre la même liste avec les mêmes filtres.
- Les crawlers indexent tes filtres. Si tu veux que Google trouve "chaussures rouges", il faut que
/products?category=shoes&color=redexiste comme URL réelle.
En Angular, la brique clé s'appelle withComponentInputBinding(). Depuis Angular 16 côté décorateurs @Input(), depuis Angular 17.1 côté signal input(). Elle branche automatiquement les query params, route params et route data sur les inputs du composant.
Setup dans provideRouter()
import { ApplicationConfig } from '@angular/core';
import { provideRouter, withComponentInputBinding, withInMemoryScrolling } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(
routes,
withComponentInputBinding(),
withInMemoryScrolling({
scrollPositionRestoration: 'enabled',
anchorScrolling: 'enabled',
}),
),
],
};
withInMemoryScrolling est le premier cadeau : Angular restaure automatiquement la position de scroll sur un back-forward. Tu n'as rien à écrire.
La page devient bête
import { Component, ChangeDetectionStrategy, inject, input, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Router, RouterLink } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
type Product = { id: string; name: string; category: string; price: number };
@Component({
selector: 'app-products',
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<select
[value]="category()"
(change)="updateCategory($any($event.target).value)"
>
<option value="">Toutes</option>
<option value="shoes">Chaussures</option>
<option value="bags">Sacs</option>
</select>
@for (product of filtered(); track product.id) {
<a [routerLink]="['/products', product.id]">{{ product.name }}</a>
}
`,
})
export default class Products {
private readonly http = inject(HttpClient);
private readonly router = inject(Router);
readonly category = input<string>('');
private readonly products = toSignal(
this.http.get<Product[]>('/api/products'),
{ initialValue: [] },
);
protected readonly filtered = computed(() =>
this.category()
? this.products().filter(p => p.category === this.category())
: this.products(),
);
protected updateCategory(value: string): void {
this.router.navigate([], {
queryParams: { category: value || null },
queryParamsHandling: 'merge',
});
}
}
Ce qui a changé :
categoryest uninput()signal branché sur?category=parwithComponentInputBinding().- Toute mutation du filtre passe par
router.navigate(). L'URL devient le state. - Au retour depuis le détail, l'URL contient toujours
?category=shoes, l'input est repopulé,filtered()renvoie le bon sous-ensemble, le scroll se restaure tout seul.
Reste le refetch HTTP. httpResource (Angular 19.2+) aide déjà : il déclare la requête comme un signal réactif et ne la relance que si ses inputs changent réellement, donc un retour sur une URL identique ne redéclenche rien tant que le composant ou le service qui le détient est resté vivant. Si ton app tourne en SSR, ajoute aussi withHttpTransferCache() à provideClientHydration() : attention, ce cache-là évite uniquement le double-fetch entre le rendu serveur et l'hydratation client au chargement initial, il ne sert à rien pour les navigations suivantes dans l'app.
Côté route, tes resolvers (les fonctions déclarées dans resolve: {...}) peuvent aussi éviter de se rejouer inutilement : runGuardsAndResolvers: 'paramsOrQueryParamsChange' sur une route dit à Angular de ne relancer resolvers et guards que si un param d'URL a effectivement changé. Assure-toi aussi que onSameUrlNavigation reste à sa valeur par défaut 'ignore', sinon Angular relance tout à chaque clic.
Cette stratégie couvre 85% des cas. Filtres, tri, pagination, onglets, recherche : tout ce qui est représentable en URL doit y aller.
Stratégie 2 - Cache en signal dans un service (le raisonnable)
Certaines choses n'ont rien à faire dans l'URL. Une liste virtuelle avec 50 000 éléments et sa position exacte. Le contenu partiel d'un draft de formulaire. Un état d'expand/collapse dans un arbre de navigation.
Ici, tu extrais le state du composant vers un service. Les composants viennent et repartent ; le service, non.
Le service qui survit
import { Injectable, inject, signal, computed } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';
type Product = { id: string; name: string; category: string; price: number };
@Injectable({ providedIn: 'root' })
export class ProductsState {
private readonly http = inject(HttpClient);
readonly category = signal<string>('');
readonly scrollTop = signal<number>(0);
readonly openedGroups = signal<Set<string>>(new Set());
readonly products = toSignal(
this.http.get<Product[]>('/api/products'),
{ initialValue: [] },
);
readonly filtered = computed(() =>
this.category()
? this.products().filter(p => p.category === this.category())
: this.products(),
);
}
toSignal() conserve la requête HttpClient : la souscription vit tant que le service vit, donc la data reste en mémoire pour toute la session. Le composant se contente de lire.
Le composant devient une projection
import { Component, ChangeDetectionStrategy, inject, afterNextRender, ElementRef, viewChild } from '@angular/core';
import { RouterLink } from '@angular/router';
import { ProductsState } from './products-state';
@Component({
selector: 'app-products',
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<select [value]="state.category()" (change)="state.category.set($any($event.target).value)">
<option value="">Toutes</option>
<option value="shoes">Chaussures</option>
<option value="bags">Sacs</option>
</select>
<div #scroller (scroll)="state.scrollTop.set($any($event.target).scrollTop)">
@for (product of state.filtered(); track product.id) {
<a [routerLink]="['/products', product.id]">{{ product.name }}</a>
}
</div>
`,
})
export default class Products {
protected readonly state = inject(ProductsState);
private readonly scroller = viewChild.required<ElementRef<HTMLDivElement>>('scroller');
constructor() {
afterNextRender(() => {
this.scroller().nativeElement.scrollTop = this.state.scrollTop();
});
}
}
Au montage suivant, state.category(), state.filtered() et state.scrollTop() renvoient les valeurs enregistrées. afterNextRender réapplique le scroll après que le DOM est prêt.
Quand providedIn: 'root' devient un problème
Un service root vit toute la session. Si l'utilisateur ne revient jamais sur cette liste, sa data mangera de la RAM pour rien. Deux atténuations :
- Fournir le service au niveau de la route via
providers: [ProductsState]dans la route parente. Le service est instancié quand la sous-arborescence est active, détruit quand elle est quittée. Idéal si tu veux garder l'état entreproductsetproducts/:id, mais tout oublier quand on part ailleurs. - Nettoyer explicitement avec
DestroyRefdans le service pour libérer les caches lourds.
Cette stratégie couvre 10% des cas restants. Tout ce qui est trop volumineux ou trop instable pour l'URL, mais qui n'a pas besoin de préserver un vrai bout de DOM.
Stratégie 3 - RouteReuseStrategy (le dernier recours)
Restent les 5% où tu dois vraiment préserver le composant lui-même. Pas son state, le composant. Cas typiques : un canvas WebGL en pleine session, une carte MapLibre avec sa position et ses tuiles chargées, un player vidéo en cours de lecture, une iframe tierce avec un état interne inaccessible.
Là, aucun signal ne te sauve : ce qui compte, c'est de ne pas détruire le HTMLCanvasElement ni le SDK JavaScript qui vit autour.
Le Router d'Angular sait faire ça, mais l'API est bas niveau. L'idée : au lieu de détruire le composant à la sortie, le Router le détache de la vue et te confie une poignée opaque appelée DetachedRouteHandle. Tu la stockes. Au retour, tu la rends au Router qui rattache le composant tel quel, DOM inclus.
L'implémentation minimale
import { RouteReuseStrategy, ActivatedRouteSnapshot, DetachedRouteHandle } from '@angular/router';
export class CachedRouteReuseStrategy implements RouteReuseStrategy {
private readonly handles = new Map<string, DetachedRouteHandle>();
shouldDetach(route: ActivatedRouteSnapshot): boolean {
return route.data['reuse'] === true;
}
store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle | null): void {
const key = this.getKey(route);
if (handle) {
this.handles.set(key, handle);
} else {
this.handles.delete(key);
}
}
shouldAttach(route: ActivatedRouteSnapshot): boolean {
return this.handles.has(this.getKey(route));
}
retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle | null {
return this.handles.get(this.getKey(route)) ?? null;
}
shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean {
return future.routeConfig === curr.routeConfig;
}
private getKey(route: ActivatedRouteSnapshot): string {
return route.pathFromRoot.map(r => r.url.join('/')).join('/');
}
}
Déclaration dans provideRouter() :
import { RouteReuseStrategy } from '@angular/router';
import { CachedRouteReuseStrategy } from './cached-route-reuse-strategy';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
{ provide: RouteReuseStrategy, useClass: CachedRouteReuseStrategy },
],
};
Puis marque explicitement les routes concernées :
export const routes: Routes = [
{
path: 'map',
loadComponent: () => import('./pages/map/map.page'),
data: { reuse: true },
},
];
Les 4 pièges qui vont te faire pester
1. Le cache grossit sans jamais rétrécir.
Le Map<string, DetachedRouteHandle> accumule une poignée par URL détachée. Chaque poignée garde une référence sur un composant, son injecteur, ses templates. Sur une session longue avec beaucoup de routes marquées reuse, c'est une fuite mémoire garantie.
Correctif : implémente une éviction par ancienneté (garde les N poignées les plus récentes et jette les autres, communément appelé LRU pour Least Recently Used), ou nettoie le cache sur des événements ciblés (Router.events filtrés sur NavigationEnd avec une politique métier). Ne t'imagine pas que "quand ça pose problème j'ajouterai" : quand ça pose problème, c'est en production avec un utilisateur qui reste 3 heures sur ton app.
2. Les guards ne s'exécutent plus quand la route est réattachée.
CanActivate, CanDeactivate, Resolve sont zappés quand Angular décide de rattacher une poignée existante. Ta permission a expiré entre-temps ? La data est stale ? Angular s'en fiche, il te ressert le composant.
Correctif : si tu as des guards critiques, marque une route reuse: false. Si tu veux quand même cacher, écoute NavigationEnd dans le composant rattaché et rejoue toi-même la vérification.
3. Les inputs bindés ne se rebindent pas comme tu crois.
withComponentInputBinding() fonctionne sur activation. Un composant rattaché depuis une poignée stockée voit ses inputs remis à jour au rattachement, oui : mais son constructeur ne se rejoue pas. Toute logique d'init dans le constructeur ne repartira jamais. Si tu comptes sur constructor() pour initialiser un state qui dépend de input(), tu vas te faire avoir.
Correctif : passe la logique d'init dans un effect() qui lit tes inputs, ou dans un linkedSignal dérivé. C'est de toute façon ce que tu devrais faire en Angular moderne.
4. Combiné avec les lazy routes, ça a longtemps été bancal.
Historiquement, les modules lazy-loaded posaient des soucis avec RouteReuseStrategy (le fameux "detached route handle memory leak" documenté sur GitHub). Le router d'Angular 15+ a assaini beaucoup de ces cas, mais les composants standalone lazy via loadComponent restent le terrain le plus stable. Si ton app est encore sur des NgModule lazy, teste minutieusement avant de généraliser.
Cette stratégie couvre 5% des cas. Vraiment. Si tu es en train de dégainer RouteReuseStrategy pour préserver un formulaire ou une position de scroll, tu as raté la stratégie 1 ou la 2.
Récap actionnable
| Ce que tu veux préserver | Stratégie |
|---|---|
| Filtres, tri, page, recherche, onglets | 1. Query params + withComponentInputBinding() |
| Scroll top d'une page classique | 1. withInMemoryScrolling({ scrollPositionRestoration: 'enabled' }) |
| Data HTTP entre navigations aller-retour | 2. Service providedIn: 'root' avec toSignal() ou httpResource |
| Position exacte dans une virtual scroll | 2. Service (au niveau de la route parente) avec scrollTop signal |
| État d'un arbre expand/collapse riche | 2. Service (au niveau de la route parente) |
| Canvas WebGL, carte, video player, iframe tiers | 3. RouteReuseStrategy sur cette route uniquement |
Trois règles pour ne pas te tromper :
- Commence toujours par la stratégie 1. Si le state est représentable en URL, il DOIT être dans l'URL. Tu gagnes le back button, le partage, le SEO, la stabilité sur refresh.
- Passe à la stratégie 2 quand l'URL devient un tortionnaire. Coche mentalement : "est-ce que ce state serait absurde à copier-coller dans un lien ?". Si oui, service.
- La stratégie 3 n'est justifiée que si détruire le composant détruirait quelque chose que tu ne peux pas reconstruire depuis un signal. Un canvas avec 200 000 vertex, un player vidéo en cours, une carte au bon zoom sur les bonnes tuiles. Sinon, tu vas te battre avec
shouldDetachpendant deux jours et introduire un memory leak silencieux pour rien.
Ton back button, c'est la première feature UX gratuite qu'Angular te donne. Encore faut-il ne pas la casser.