~9 min de lecture
NgRx Signal Store : les hooks onInit et onDestroy
Tu as un store @ngrx/signals. Il expose un état, des computed, des méthodes. Reste une question bête : qui déclenche
le chargement initial des données ?
La réponse par défaut, c'est le composant :
export default class ShoppingListPage {
private readonly store = inject(shoppingListStore);
constructor() {
this.store.loadProducts();
}
}
Ça marche. Et ça introduit un bug latent : le jour où un deuxième composant utilise ce store, soit il oublie d'appeler
loadProducts() et affiche une liste vide, soit il l'appelle à son tour et les données sont chargées deux fois.
withHooks supprime la question. Mais il s'accompagne d'une asymétrie qui surprend tout le monde : inject()
fonctionne dans onInit et plante dans onDestroy. Cet article explique pourquoi, et comment injecter quand même.
Tous les exemples tournent sous Angular 20 et
@ngrx/signals20.
withHooks, le cycle de vie du store
withHooks est une feature de signalStore, c'est-à-dire une fonction qui reçoit le store en construction et le
renvoie enrichi - exactement comme withState, withComputed ou withMethods que tu empiles déjà. Celle-ci ajoute le
cycle de vie : ce qui doit se passer quand le store naît et quand il meurt.
import { signalStore, withHooks, withState } from '@ngrx/signals';
export const shoppingListStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withHooks({
onInit(store) {
// le store vient d'être instancié
},
onDestroy(store) {
// son injecteur est détruit
},
})
);
Les deux hooks sont optionnels, et tu reçois le store en argument - avec son état, ses computed et ses méthodes déjà en place.
Le cas d'usage principal : charger au démarrage
Sur une app de liste de courses, le chargement initial vit dans le store, une fois pour toutes :
export const shoppingListStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withComputed(/* ... */),
withReducer(/* ... */),
withEffects(/* ... */),
withHooks({
onInit(store) {
inject(ProductsPort)
.getAll()
.subscribe((products) => {
patchState(store, { products });
});
},
})
);
Note le inject(ProductsPort) à l'intérieur du hook. On verra dans un instant pourquoi inject() passe dans
onInit et pas dans onDestroy.
Aucun composant n'a plus à savoir que des données doivent être chargées. Qui injecte le store obtient un store déjà en train de se remplir.
Pourquoi le store n'injecte pas localStorage
Le détail qui compte : le store n'injecte ni localStorage, ni un service concret. Il injecte un port, c'est-à-dire
un contrat abstrait.
export abstract class ProductsPort {
abstract getAll(): Observable<Product[]>;
abstract save(product: Product): void;
abstract remove(productId: Product['id']): void;
}
L'implémentation est choisie ailleurs, dans la configuration de l'application :
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideZonelessChangeDetection(),
provideRouter(appRoutes),
{ provide: ProductsPort, useClass: LocalStorageProductsAdapter },
],
};
C'est de l'inversion de contrôle : le store déclare ce dont il a besoin, il ne choisit pas qui le lui fournit.
Passer de localStorage à une API HTTP, c'est changer ce provider, et le store ne bouge pas. (Un adaptateur HTTP
demandera aussi un provideHttpClient() à côté, mais rien de tout ça ne remonte jusqu'au store.)
💡 Ici le port est une classe abstraite, qui sert à la fois de contrat et de token d'injection. Un InjectionToken
associé à une interface marche tout aussi bien ; c'est d'ailleurs le pattern retenu dans
Ports & Adapters en Angular.
Ce que fait vraiment @ngrx/signals
Pour comprendre l'asymétrie annoncée en intro, il faut ouvrir le capot. Le code ci-dessous n'est pas du code à écrire :
c'est le JavaScript compilé de la bibliothèque, extrait de
node_modules/@ngrx/signals/fesm2022/ngrx-signals.mjs et reformaté pour la lisibilité. Il vaut le détour parce que la
documentation ne tranche pas la question, alors que ces cinq lignes la tranchent définitivement.
constructor() {
const innerStore = features.reduce((store, feature) => feature(store), getInitialInnerStore());
const { stateSignals, props, methods, hooks } = innerStore;
// ...
const { onInit, onDestroy } = hooks;
if (onInit) { onInit(); }
if (onDestroy) { inject(DestroyRef).onDestroy(onDestroy); }
}
Trois lignes portent tout : le reduce, et les deux dernières. Elles donnent trois conséquences pratiques qui
expliquent tout le reste de l'article :
- Toutes les features sont appliquées dans le constructeur (
features.reduce). Or un constructeur instancié par l'injection de dépendances d'Angular est un contexte d'injection : la fenêtre pendant laquelleinject()est légal. onInitest appelé de façon synchrone dans ce même constructeur. Il hérite donc de ce contexte, et tu peux y faireinject().onDestroyn'est pas appelé là. Il est seulement enregistré auprès deDestroyRef- le service Angular qui permet de faire exécuter une callback à la destruction de l'injecteur courant. Il sera donc exécuté plus tard, et hors contexte d'injection.
Quand onDestroy se déclenche vraiment
Avant de parler du piège, il faut savoir quand ce hook tourne. Puisqu'il passe par DestroyRef, il se déclenche à la
destruction de l'injecteur qui a créé le store. Il y a donc deux cas qui comptent :
| Portée du store | Injecteur | onDestroy se déclenche |
|---|---|---|
{ providedIn: 'root' } |
racine | à la destruction de l'application |
providers d'un composant |
composant | à chaque destruction du composant |
Le premier cas est le plus courant, et c'est le plus décevant : dans une application monopage (SPA), l'application n'est pas détruite tant que l'onglet reste ouvert, donc le hook ne tourne pratiquement jamais.
⚠️ En SSR, c'est l'inverse. L'application est reconstruite et détruite à chaque requête, donc un onDestroy de
store root s'exécute à chaque rendu serveur. Si tu y mets quelque chose de coûteux, tu le paies à chaque page servie.
Le second cas, en revanche, est celui où onDestroy sert vraiment : fermer une WebSocket, arrêter un polling, vider un
cache qui n'a de sens que pendant la visite d'un écran.
🚨 Le faux ami : les providers de route
Il manque un troisième cas dans ce tableau, et son absence est volontaire. On imagine naturellement qu'un store placé
dans les providers d'une route meurt quand on quitte cette route. En Angular 20, ce n'est pas ce qui se passe.
L'injecteur créé pour une route est un injecteur d'environnement, de la même nature que celui de la racine, mais avec sa propre portée - une troisième portée, distincte des deux du tableau. Il naît une seule fois, puis reste en cache sur l'objet de configuration de la route, et rien, dans le routeur, ne le détruit jamais. Deux conséquences concrètes :
onDestroyne se déclenche pas quand tu quittes la route ;onInitne se rejoue pas quand tu y reviens : le store est réutilisé avec son état précédent.
Autrement dit, scoper un store à une route pour « recharger à chaque visite » ne marche pas, et y mettre un onDestroy
pour fermer une WebSocket produit une fuite silencieuse. Si tu as besoin d'une portée qui meurt vraiment, descends au
niveau du composant. (Angular 21.1 introduit withExperimentalAutoCleanupInjectors, une option expérimentale de
provideRouter qui permet de détruire ces injecteurs - opt-in, et conditionnée à ce que la RouteReuseStrategy renvoie
true pour shouldDestroyInjector.)
Le piège : inject() dans onDestroy
Prenons le cas qui a du sens - un store scopé à un composant, qui doit signaler son départ à un service d'analytics :
@Component({
selector: 'app-live-board',
providers: [liveBoardStore], // store scopé au composant
changeDetection: ChangeDetectionStrategy.OnPush,
template: `...`,
})
export class LiveBoard {}
// ❌ Échoue à l'exécution
export const liveBoardStore = signalStore(
withState(initialState),
withHooks({
onDestroy() {
inject(AnalyticsService).flush();
},
})
);
Ici onDestroy se déclenche bien à chaque fermeture du composant. Mais quand il s'exécute, le constructeur est
terminé depuis longtemps : il n'y a plus de contexte d'injection, et Angular lève (avec, à la suite, un lien vers sa page d'erreur, tronqué ici parce que son hôte dépend de ta version majeure) :
NG0203: The `AnalyticsService` token injection failed. `inject()` function must be called from an injection context such as a constructor, a factory function, a field initializer, or a function used with `runInInjectionContext`.
Le même appel dans onInit fonctionne parfaitement. C'est cette asymétrie qui surprend : deux hooks déclarés côte à
côte, un seul peut injecter.
(Si NG0203 ne te dit rien, l'erreur et ses autres déclencheurs sont décortiqués dans
NG0203, inject hors contexte.)
La solution : la forme factory
withHooks accepte une deuxième forme : une fonction qui reçoit le store et renvoie les hooks.
// ✅ Fonctionne
export const liveBoardStore = signalStore(
withState(initialState),
withHooks((store) => {
const analytics = inject(AnalyticsService);
const feed = inject(LiveFeedPort);
return {
onInit() {
feed.connect();
},
onDestroy() {
feed.disconnect();
analytics.flush();
},
};
})
);
La factory est elle-même exécutée pendant le features.reduce(...) du constructeur - c'est une feature comme les
autres, et l'extrait plus haut montre que le reduce a lieu dans le constructeur. Elle est donc dans le contexte
d'injection. Tu injectes une fois, et les deux hooks capturent les références obtenues.
Quelle forme choisir ?
| Situation | Forme |
|---|---|
Seulement onInit, une ou deux dépendances |
objet : withHooks({ onInit() {} }) |
onDestroy a besoin d'une dépendance |
factory : injecte avant, jamais dans le hook |
| Les deux hooks partagent la même dépendance | factory, pour n'injecter qu'une fois |
La forme objet est plus courte, la forme factory est plus sûre. En cas de doute, la factory ne coûte que deux lignes de plus.
Et le désabonnement, alors ?
Question légitime devant le .subscribe() du onInit : qui le nettoie ?
Pour un store root dans une application monopage, la souscription vit aussi longtemps que l'application : il n'y a rien à nettoyer. Dès que le store est scopé à un composant, que l'application est rendue côté serveur, ou que la source émet en continu, il faut la fermer. (Pour une portée de route, rien ne fermera la souscription à ta place - c'est le faux ami vu plus haut.)
Deux options. La première est rxMethod, l'utilitaire qui encapsule une opération RxJS et gère lui-même son
désabonnement - attention, il vit dans le point d'entrée secondaire @ngrx/signals/rxjs-interop, pas dans
@ngrx/signals. La seconde, si tu restes sur un subscribe manuel, est la forme factory avec un DestroyRef récupéré
dans le contexte d'injection puis passé à takeUntilDestroyed() (de @angular/core/rxjs-interop) :
withHooks((store) => {
const productsPort = inject(ProductsPort);
const destroyRef = inject(DestroyRef);
return {
onInit() {
productsPort
.getAll()
.pipe(takeUntilDestroyed(destroyRef))
.subscribe((products) => patchState(store, { products }));
},
};
})
Encore une fois, c'est la factory qui rend la chose possible : inject(DestroyRef) doit être appelé avant que le
constructeur ne rende la main.
Ce que ça donne côté test
Un store qui charge ses données tout seul se teste sans cérémonie particulière. La substitution du port se fait une fois, globalement, dans le setup de test :
beforeEach(() =>
TestBed.configureTestingModule({
providers: [...appConfig.providers],
}).overrideProvider(ProductsPort, {
useFactory: () => new InMemoryProductsAdapter(),
})
);
⚠️ Ce montage ne suffit pas à lui seul. L'adaptateur en mémoire démarre sur une liste vide, donc onInit charge
zéro produit. Un test qui a besoin de données déjà présentes au démarrage doit refaire un overrideProvider avec un
adaptateur amorcé, dans le beforeEach de son propre bloc :
const product: Product = {
id: '11111-1111-1111-1111-111111111111',
name: 'Chocapic',
quantity: null,
category: 'autres',
pickedUp: false,
};
let fixture: ComponentFixture<ShoppingListPage>;
let pageModel: PageModel;
beforeEach(async () => {
fixture = TestBed.overrideProvider(ProductsPort, {
useFactory: () => {
const adapter = new InMemoryProductsAdapter();
adapter.products.next([product]); // l'état de départ du stockage
return adapter;
},
}).createComponent(ShoppingListPage);
pageModel = new PageModel(fixture);
await fixture.whenStable();
});
Le hook onInit s'exécute alors normalement, contre un adaptateur en mémoire pré-rempli. Le test peut se contenter
d'observer l'écran, via un petit objet qui encapsule les requêtes DOM :
it('should display product from storage', async () => {
pageModel.assertProductExists(product);
});
Ni mock ni espion dans ce test : on remplace une implémentation par une autre, et on vérifie ce qui s'affiche. C'est tout l'intérêt d'avoir injecté un port plutôt qu'une implémentation concrète - le test décide de l'état du stockage sans que le store, lui, sache d'où viennent ses données.
Récap actionnable
| Ce que tu veux faire | Comment |
|---|---|
| Charger des données au démarrage du store | withHooks({ onInit }), inject() autorisé à l'intérieur |
| Libérer une ressource à la mort du store | forme factory, inject() avant de renvoyer les hooks |
Utiliser une dépendance dans onDestroy |
l'obtenir avant : forme factory, jamais inject() dans le hook |
Savoir si ton onDestroy tourne |
selon qui fournit le store : racine ou composant, jamais une route |
Fermer une souscription ouverte dans onInit |
rxMethod, ou takeUntilDestroyed(destroyRef) capturé dans la factory |
| Tester un store qui charge tout seul | substituer le port, et amorcer l'adaptateur quand le test a besoin d'un état initial |
Trois pièges à garder en tête, parce qu'ils ne produisent aucune erreur et se voient seulement en production : un
onDestroy sur un store providedIn: 'root' ne se déclenchera pratiquement jamais dans une SPA ; ce même hook se
déclenchera au contraire à chaque requête si l'application est rendue côté serveur ; et un store scopé à une route
ne sera jamais détruit du tout.
La règle d'or
Si un composant doit appeler une méthode de chargement pour que ton store soit utilisable, ce chargement appartient au store.
Pour aller plus loin
- Faut-il un store, d'abord ? La question est posée sans détour dans faut-il un store Angular ?
- Le retour d'expérience qui a précédé cet article : découverte du NgRx Signal Store
- Structurer et tester proprement tes apps Angular, module par module, avec EasyAngularKit