~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/signals 20.


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 :

  1. 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 laquelle inject() est légal.
  2. onInit est appelé de façon synchrone dans ce même constructeur. Il hérite donc de ce contexte, et tu peux y faire inject().
  3. onDestroy n'est pas appelé là. Il est seulement enregistré auprès de DestroyRef - 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 :

  • onDestroy ne se déclenche pas quand tu quittes la route ;
  • onInit ne 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

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