~16 min de lecture
Ton guard ne devrait pas dispatcher : où mettre ce chargement
Tu ouvres le guard qui protège /users/:userId. Trois lignes :
export const userGuard: CanActivateFn = () => {
inject(Store).dispatch(UserActions.load());
return true;
};
Ça marche, tant que la route reste simple. Et c'est pour ça que tout le monde en écrit : le guard est le premier endroit du cycle de navigation qui « voit » qu'on arrive sur une page, et l'un des rares où le contexte d'injection est déjà disponible. Il est tentant d'y brancher le chargement.
Sauf qu'un guard n'est pas un point d'entrée de chargement. C'est un arbitre : on lui pose une question fermée, il répond. Le transformer en déclencheur d'effets de bord ouvre une catégorie de bugs particulièrement pénibles : des états qui restent bloqués en loading et des requêtes qui partent pour des pages que l'utilisateur ne verra jamais.
Voici pourquoi, et où ce chargement doit aller à la place - une réponse, et les deux cas qui y échappent.
Valide Angular 17+, testé sur Angular 22.0.7 et NgRx 22.0.0.
GuardResultdate de 17.3,RedirectCommandde 18,httpResource()de 19.2 - stable seulement depuis 22.
TL;DR
Sous NgRx, la réponse est unique : un effect NgRx (côté store, pas l'effect() des signals) sur routerNavigatedAction. Des quatre endroits possibles - guard, resolver, effect, composant -, c'est le seul dont le déclencheur soit conditionné à la réussite : l'action n'existe que sur NavigationEnd. Le guard et le resolver dispatchent quand la navigation peut encore mourir ; le composant, selon le point choisi, avant qu'elle soit jouée ou après, mais sans regarder l'issue.
Deux cas y échappent, et ils donnent la même réponse à la question d'entrée : non, cette donnée ne sort pas de l'écran. Reste alors à savoir dans quel état la page a le droit d'apparaître.
- La page doit être complète à l'arrivée : un
ResolveFn. - Un squelette fait l'affaire :
input()+httpResource().
Le guard, lui, garde son métier - autoriser, refuser, rediriger - avec un cas limite : quand la donnée est la décision.
Pourquoi le dispatch dans un guard est un problème
1. Le contrat n'est pas celui-là
Depuis Angular 17.3, un guard répond GuardResult, éventuellement enveloppé dans une Promise ou un Observable : boolean | UrlTree | RedirectCommand, ce dernier depuis Angular 18. Rien dans cette signature ne dit « je déclenche aussi un chargement ». Le jour où quelqu'un lit canActivate: [userGuard] dans le fichier de routes, il lit « cette route est protégée ». Il ne lit pas « cette route charge son utilisateur ». Les deux responsabilités sont fusionnées dans un nom qui n'en annonce qu'une.
2. Les guards d'un même canActivate démarrent tous, sauf parfois le dernier
C'est là que la plupart des gens se trompent, et c'est mesurable. Angular n'enchaîne pas les guards d'un même tableau canActivate : il les enveloppe dans un combineLatest et retient la première valeur non-true dans l'ordre du tableau. L'ordre est celui de la décision, pas celui de l'exécution.
Concrètement, avec canActivate: [adminGuard, userGuard], si adminGuard est asynchrone et finit par refuser, userGuard a quand même dispatché. Encore faut-il que le refus soit asynchrone : ici, un return false immédiat démonte la chaîne avant que userGuard ne démarre. Le test qui le prouve, sur Angular 22.0.7 :
@Component({ template: 'ok' })
class Dummy {}
it('les guards du même tableau canActivate démarrent tous', async () => {
const calls: string[] = [];
// Refuse, mais après 20 ms : il est premier dans le tableau.
const denies: CanActivateFn = () => {
calls.push('denies:start');
return timer(20).pipe(map(() => false));
};
// Effet de bord synchrone, second dans le tableau.
const sideEffect: CanActivateFn = () => {
calls.push('sideEffect:start');
return of(true);
};
TestBed.configureTestingModule({
providers: [
provideRouter([
{ path: 'x', canActivate: [denies, sideEffect], component: Dummy },
]),
],
});
const ok = await TestBed.inject(Router).navigateByUrl('/x');
expect(ok).toBe(false);
expect(calls).toEqual(['denies:start', 'sideEffect:start']);
});
La navigation est refusée, et l'effet de bord a bien eu lieu.
3. Un refus en aval n'annule pas l'action déjà partie
Le router arbitre les guards par lots, via un opérateur interne nommé prioritizedGuardValue - notamment un tableau canActivate, ou les canActivateChild d'un ancêtre. Tout un lot démarre ensemble, à une exception près qui ne concerne que canActivate : le dernier guard du tableau ne démarre pas si, parmi ceux qui le précèdent, le premier à n'avoir pas répondu true de façon synchrone a refusé - false, UrlTree ou RedirectCommand - de façon synchrone lui aussi. Si tous ceux qui le précèdent répondent true synchroniquement, ou si le premier à ne pas le faire est encore en attente, tout le tableau démarre. Piège : une Promise déjà résolue compte comme asynchrone, donc un async () => false ne fait sauter personne. Dans un tableau canActivateChild, le départ est tout ou rien à l'échelle du groupe d'un ancêtre : quand le groupe démarre, aucun de ses guards ne saute, même derrière un refus synchrone ; et ce qui peut ne pas démarrer, c'est le groupe entier - plusieurs guards d'un coup. Le reste de l'activation s'enchaîne : les canActivateChild des ancêtres avant le canActivate de la route activée, puis une route après l'autre.
Ta navigation a donc d'autres façons de mourir après ton dispatch : un guard d'une route activée plus loin, un canActivateChild d'ancêtre rejoué dans un check plus profond, un resolver qui échoue, ou une deuxième navigation qui remplace la première.
Dans ces cas, l'action est dans le store, le reducer a mis loading: true, et le composant qui l'aurait remis à false n'est pas monté. Une navigation ultérieure peut encore le monter et refermer le loading, mais rien ne le garantit : si elle échoue à son tour, tu as deux actions parties et toujours aucun écran. Tu as alors un state qui décrit une page qui n'existe pas, et le symptôme arrive plus tard, ailleurs, sous la forme d'un spinner qui ne part plus.
4. Le guard se rejoue, et pas quand tu crois
Par défaut, Angular relance les guards d'une route quand ses paramètres changent (runGuardsAndResolvers non renseigné tombe dans la branche 'paramsChange', mécanisme détaillé dans le cas des pageviews fantômes). Naviguer de /users/1 vers /users/2 rejoue le guard, donc redispatch. Changer seulement un query param ne le rejoue pas. Vérifié :
it('rejoue le guard quand un param de chemin change', async () => {
let runs = 0;
const counting: CanActivateFn = () => {
runs += 1;
return true;
};
TestBed.configureTestingModule({
providers: [
provideRouter([
{ path: 'users/:id', canActivate: [counting], component: Dummy },
]),
],
});
const router = TestBed.inject(Router);
await router.navigateByUrl('/users/1');
await router.navigateByUrl('/users/2');
await router.navigateByUrl('/users/2?tab=info');
// 2 : les deux :id, et pas le changement de query param seul.
expect(runs).toBe(2);
});
Ta politique de rechargement est donc dictée par un défaut du router que personne dans l'équipe n'a choisi.
La réponse : réagir à la navigation depuis le store
Comparons les quatre emplacements en lice : le guard, un resolver, un effect, le composant. Ce qui les sépare tient en une question : au moment où l'action part, la navigation peut-elle encore mourir ?
Une navigation traverse quatre phases ordonnées : guards, resolvers, chargement des chunks loadComponent (les fichiers JS que le router va chercher au dernier moment), instanciation des composants. Les chunks de loadChildren, eux, se chargent bien plus tôt - pendant la reconnaissance de la route, après canMatch mais avant canActivate. L'endroit d'où part le dispatch décide de ce qu'il laisse devant lui : les phases déjà franchies ne peuvent plus faire échouer la navigation, celles qui restent le peuvent encore. Guard, resolver, effect, composant : ils n'en laissent pas les mêmes.
| D'où part le dispatch | La navigation peut-elle encore échouer après ? |
|---|---|
| Guard | Oui : tout ce qui suit, plus une navigation concurrente |
| Resolver qui dispatche son résultat | Oui : les resolvers suivants, le chunk loadComponent, l'instanciation des composants, une navigation concurrente |
Effect sur routerNavigatedAction |
Non : l'action part sur NavigationEnd, quand la navigation a déjà réussi |
| Composant qui dispatche | Presque plus : les trois premières phases sont derrière lui ; il ne reste que la sienne |
Le resolver n'apporte pas grand-chose : il ferme la phase des guards, et rien d'autre - ils passent tous avant le premier resolver. Tout le reste demeure, y compris pour un resolver seul sur une feuille, sans frère ni resolver plus profond : il suffit que son chunk ne se télécharge pas. Le cas le plus coûteux à débusquer reste le frère qui redirige : il renvoie un RedirectCommand, navigateByUrl se résout à true. La fuite ne se voit donc pas là où tu la cherches - il faut aller lire router.events, où un NavigationCancel la signale.
Que les trois premières phases soient bien derrière le composant, trois échecs le montrent : quand un guard refuse, qu'un resolver échoue ou qu'un chunk ne se charge pas, son constructeur n'est pas appelé - et il l'est quand la navigation aboutit. Reste sa propre phase, et elle n'est pas vide : si un composant activé par la même navigation dans un autre router-outlet, un outlet nommé, lève une exception dans son constructeur, l'URL revient en arrière alors que ton action est déjà partie. Le trou est étroit - un composant enfant du même arbre ne le produit pas, il naît à la détection de changements suivante - mais c'est exactement le spinner bloqué du début.
Pas de surcoût de chargement non plus : le composant garde son httpResource et dispatche depuis la valeur qu'il en reçoit - mesuré, une seule requête part. Ce qui l'écarte n'est donc ni le nombre de requêtes, ni vraiment la sûreté, vu l'étroitesse du trou. C'est la question de savoir à qui la donnée appartient : faire de cet écran le chargeur de ce que d'autres lisent rend ces autres écrans tributaires de sa visite.
D'où la réponse. Des quatre, l'effect sur routerNavigatedAction est le seul dont le déclencheur soit conditionné à la réussite de la navigation, et c'est ce qui le désigne - y compris quand la page n'a aucun sens sans la donnée. Le composant, lui, n'a pas un moment mais quatre. Depuis son constructeur, il dispatche avant même que la navigation soit jouée ; depuis ngOnInit, un effect() ou la valeur d'un httpResource, il dispatche après - mais sans consulter l'issue. Dans les trois morts ordinaires il n'est pas instancié du tout, donc rien ne part ; dans le trou étroit de l'outlet nommé, il l'est, et l'URL revient en arrière pendant que sa page reste affichée. L'arbitrage se paie : tu acceptes d'ouvrir la page vide, en échange de la certitude qu'aucune action ne part pour rien. Le resolver te donnerait l'inverse. Si l'effect l'emporte, c'est qu'un écran vide se voit tout de suite, là où une action partie pour rien laisse un state faux que rien ne relie à sa cause.
Concrètement, l'outil est un effect NgRx - une fonction qui écoute le flux d'actions du store et en réémet d'autres, à ne pas confondre avec l'effect() des signals - branché sur @ngrx/router-store, le paquet qui reflète l'état du router dans le store et dispatche une action aux étapes clés d'une navigation. Le branchement tient en trois providers :
bootstrapApplication(App, {
providers: [
provideStore({ router: routerReducer }),
provideRouterStore(),
provideEffects({ loadUserOnNavigation }),
],
});
Le piège de timing, qui n'est pas là où on le dit
@ngrx/router-store émet cinq actions, et deux ressemblent à ce que tu cherches. Elles ne sont pas interchangeables.
routerNavigationAction est dispatchée par défaut sur RoutesRecognized, c'est-à-dire avant que les guards et les resolvers ne tournent. C'est le sens de l'option navigationActionTiming, qui vaut PreActivation par défaut et qu'on peut basculer en PostActivation. Sous ce défaut, un effect branché là réagit à des navigations qui seront peut-être annulées : exactement le reproche fait au guard, déplacé d'un cran. En PostActivation, l'action passe elle aussi sur NavigationEnd et le défaut disparaît - mais tu déplaces alors le timing de toute l'app pour un seul effect.
routerNavigatedAction est dispatchée sur NavigationEnd, uniquement. Guards passés, resolvers résolus, route activée. Et navigationActionTiming ne l'affecte pas : l'option ne pilote que routerNavigationAction. C'est celle-là que tu veux, et c'est pourquoi le fameux réglage de timing est un faux problème dès lors que tu écoutes la bonne action.
Filtrer sans se faire piéger par la prochaine refonte d'URL
routerNavigatedAction est émise sur toute navigation qui aboutit, quelle que soit la route. Un effect qui ne filtre pas recharge ton utilisateur quand tu vas dans les paramètres. Mais le filtrage à base de url.startsWith('/users/') ne marche que jusqu'à la première refonte d'URL. Voici la version qui tient :
const { selectRouteParam } = getRouterSelectors();
// Émet le param de route uniquement quand une navigation aboutie le fournit.
export const routeParam = (param: string) => {
const actions$ = inject(Actions);
const store = inject(Store);
const paramSelector = selectRouteParam(param);
return actions$.pipe(
ofType(routerNavigatedAction),
concatLatestFrom(() => store.select(paramSelector)),
map(([, value]) => value),
filter((value): value is string => value !== undefined),
distinctUntilChanged(),
);
};
L'effect se réduit alors à sa logique métier :
export const loadUserOnNavigation = createEffect(
() => {
const api = inject(UserApi);
return routeParam('userId').pipe(
switchMap((userId) =>
api.getById(userId).pipe(
map((user) => UserActions.loaded({ user })),
catchError(() => of(UserActions.loadFailed())),
),
),
);
},
{ functional: true },
);
getRouterSelectors() s'appelle au niveau du module, pas dans le helper : chaque appel fabrique une nouvelle chaîne de sélecteurs mémoïsés, et le sortir permet à tous tes helpers de partager la même chaîne de selectRouterState vers selectRouteParams. Le sélecteur final sort du concatLatestFrom pour la même raison : selectRouteParam est une fabrique, qui en construirait un neuf à chaque navigation si on l'y laissait.
distinctUntilChanged() est une décision de produit déguisée en opérateur. Avec l'opérateur : aller sur /users/1, partir ailleurs, puis revenir sur /users/1 ne recharge pas. Sans : ça recharge à chaque navigation qui fournit le param. Tranche explicitement.
Le reclic sur le lien de la page courante, lui, se joue un cran plus haut. Par défaut (onSameUrlNavigation: 'ignore'), cette navigation se solde par un NavigationSkipped et ne dispatche aucune action router-store : il ne se passe rien, quel que soit ton opérateur. Avec 'reload', l'action part, et c'est alors distinctUntilChanged() qui la neutralise : le param n'a pas changé.
selectRouteParam lit les params de la route feuille, celle obtenue en descendant les firstChild jusqu'au bout. Ce qu'elle voit d'un :userId porté plus haut dépend de paramsInheritanceStrategy, dont le défaut a changé : 'emptyOnly' jusqu'à Angular 21 inclus, 'always' à partir d'Angular 22 (parmi les changements de défaut de la v22). Sous 'emptyOnly', l'héritage s'interrompt à tout niveau où le parent a un component ou un loadComponent et où l'enfant a un chemin non vide ; sous 'always', jamais. Il suffit d'un tel niveau entre le porteur du param et la feuille pour que la v22 change ton résultat.
Par défaut, @ngrx/router-store tronque l'événement transporté dans le payload à { id, url, urlAfterRedirects }, alors que le type annonce un NavigationEnd. Le type ment sur ce que tu as à l'exécution. Le routerState, lui, porte l'arbre complet des routes, sérialisé par le MinimalRouterStateSerializer, le défaut : params, queryParams, data, url, fragment, outlet, title, firstChild / children, et un routeConfig sans component. Pas de paramMap.
Une seule option restitue l'événement complet et le paramMap : routerState: RouterState.Full. Elle a un prix, et il ne se devine pas - le sérialiseur complet met la classe du composant dans le state, que strictStateImmutability gèle en dev, et Angular ne peut plus l'instancier : Cannot add property __NG_ELEMENT_ID__, object is not extensible. Le réflexe de couper strictActionImmutability ne sert à rien, les runtime checks d'action ignorant les actions @ngrx/* : c'est bien l'immutabilité d'état qu'il faut assouplir, ou navigationActionTiming qu'il faut passer en PostActivation.
Deux cas où la donnée ne sort pas de l'écran
Ils ont un point commun : la donnée ne sort pas de l'écran. Personne ne la lit ailleurs, donc rien n'oblige à la faire transiter par le store - avoir NgRx dans son app ne veut pas dire que tout y passe. Sans action à faire partir, le state bloqué en loading sort du tableau. L'autre moitié du problème, elle, ne disparaît que d'un côté : la requête d'un httpResource ne part qu'au premier rendu, donc jamais pour une page invisible, alors que celle d'un resolver part avant et peut toujours partir pour une page que personne ne verra.
La page doit être complète à l'arrivée : le resolver
Un resolver fait exactement ce que le guard bricolait, avec le bon contrat : il produit une donnée, la navigation l'attend, et le composant la reçoit.
export const userResolver: ResolveFn<User> = (route) => {
const router = inject(Router);
const id = route.paramMap.get('userId');
return inject(UserApi).getById(id!).pipe(
catchError(() => of(new RedirectCommand(router.parseUrl('/users')))),
);
};
Trois propriétés le rendent supérieur au guard ici : la navigation attend, donc le composant est monté avec sa donnée ; le resolver est annulé au bon endroit - un guard en vol est démonté lui aussi, mais il a déjà lâché son effet de bord, là où le resolver n'a encore rien écrit nulle part ; et depuis Angular 18, un RedirectCommand renvoyé par le resolver fait de l'échec un chemin de première classe, sans navigate() impératif noyé dans un catchError.
Le prix, c'est exactement ce que tu as accepté en choisissant ce cas : pendant le chargement, l'utilisateur reste sur la page précédente, sans rien. Ni squelette ni chargement progressif - à 800 ms, l'app a l'air gelée, et il te faut une barre de progression branchée sur les événements du router. À noter aussi : les resolvers d'une même route partent en parallèle, la navigation attend le plus lent.
Si tes resolvers sont encore des classes, la bascule est traitée dans les 5 raisons d'arrêter d'écrire des classes.
Un squelette fait l'affaire : le composant
L'option la plus simple, et de plus en plus souvent la plus légitime. withComponentInputBinding(), passé à provideRouter() avec tes routes, injecte les params de route dans les input() ; httpResource() fait le reste :
@Component({
selector: 'app-user-detail',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (user.isLoading()) {
<app-skeleton />
} @else if (user.hasValue()) {
<h1>{{ user.value().name }}</h1>
} @else {
<app-error />
}
`,
})
export default class UserDetail {
readonly userId = input.required<string>();
readonly user = httpResource<User>(() => `/api/users/${this.userId()}`);
}
La donnée est déclarée à côté de l'écran qui l'affiche, le rechargement suit le paramètre sans que tu l'orchestres, et l'état de chargement est un signal que le template lit.
À noter : httpResource() n'est stable que depuis Angular 22. Avant, elle se comporte comme décrit, mais tu construis sur une API encore susceptible de bouger.
Et si tu n'as pas de store du tout, la question ne se pose même pas : introduire NgRx uniquement pour sortir un dispatch d'un guard est disproportionné. Le composant coûte deux lignes de plus qu'un composant ordinaire, et zéro dépendance.
Et le guard, alors ?
Il lui reste un métier, et c'est un métier entier : décider si la navigation a lieu, et vers où. true, false, une UrlTree, et un RedirectCommand depuis Angular 18. Rien d'autre - la route à retenir, elle, se décide un cran plus haut, avec canMatch.
Il existe un cas limite légitime, et mieux vaut le nommer que le nier : dispatcher une action dont dépend la décision d'autorisation elle-même, comme charger les permissions pour pouvoir les évaluer. Ce n'est plus un effet de bord, c'est le calcul de la réponse, à condition d'en attendre le résultat :
export const canManageBilling: CanActivateFn = () => {
const store = inject(Store);
const router = inject(Router);
store.dispatch(PermissionsActions.ensureLoaded());
return store.select(selectPermissions).pipe(
filter((permissions) => permissions !== null),
take(1),
map((permissions) =>
permissions.includes('billing:manage') ? true : router.parseUrl('/forbidden'),
),
);
};
Deux conditions pour que ça reste sain. Le guard doit attendre le résultat au lieu de laisser passer aveuglément, ce qui rend le dispatch nécessaire et pas décoratif. Et l'action doit être idempotente : ensureLoaded ne relance rien si les permissions sont déjà là, sinon le rejeu sur changement de param déclenche une requête à chaque fois.
Si ton dispatch ne remplit pas ces deux conditions, il n'appartient pas au guard.
Récap actionnable
- Cherche tes dispatches dans les guards :
grep -rn dispatch --include='*.guard.ts' src. Pour chacun, pose la question du responsable. - La donnée conditionne-t-elle la décision d'autorisation ? Si oui, garde-la dans le guard, mais attends son résultat et rends l'action idempotente.
- Cette donnée sort-elle de l'écran ? Si oui et que tu as déjà un store, c'est l'effect sur
routerNavigatedAction- y compris quand la page n'a aucun sens sans elle, en assumant alors l'écran vide. Si non, demande-toi dans quel état la page a le droit d'apparaître. Complète : resolver. Un squelette suffit : composant. - Si tu pars sur router-store, écoute
routerNavigatedActionet pasrouterNavigationAction, filtre viagetRouterSelectors(), et tranche explicitement ledistinctUntilChanged(). - Si tu migres vers Angular 22 sans avoir jamais posé
paramsInheritanceStrategyexplicitement, cherche les routes enfants qui lisent un param déclaré plus haut : le défaut est passé de'emptyOnly'à'always', et la règle exacte est donnée avecselectRouteParam.
Le débat n'est pas « guard ou router-store ». C'est qui est responsable du chargement de cette donnée. Le guard n'est presque jamais la réponse, sauf quand la donnée est la décision : il est juste le premier endroit du cycle où tu as l'injection sous la main.