skip to content

With NgRx SignalStore, when do withHooks' onInit and onDestroy run for a store provided at root versus one in a component's providers?

level: middleimportance: should knowfreq 42%

answer

  1. constructor runs the features
  2. created on first inject
  3. onDestroy via the injector's DestroyRef
  4. root lives as long as the app
  5. factory form for injected deps

basics

~20 s

A SignalStore's onInit runs once per instance, in its constructor on first injection, inside the injection context. onDestroy runs when the creating injector is destroyed: with the component for a component-provided store, at application teardown for a root store.

solid answer

~50 s

`signalStore()` builds a class whose constructor applies the features and then calls the merged `onInit` hook, so `onInit` runs **once per store instance**, when Angular first creates it, inside the injection context: `inject()`, `effect()` and `takeUntilDestroyed()` all work there. `onDestroy` is registered with the `DestroyRef` of the injector that created the instance. With `{ providedIn: 'root' }` there is one instance and `onDestroy` fires only when the root injector is torn down, in practice at application shutdown. A store listed in a component's `providers` gets a fresh instance per component instance, so both hooks run for every component that is created and destroyed. Without a config object the store is not registered anywhere and must be provided before injection. The object form's `onDestroy` does not run in the injection context; use the factory form when `onDestroy` needs an injected service.

code

ts · 29 lines
ts
import { Component, inject, input } from '@angular/core';
import { patchState, signalStore, withHooks, withMethods, withState } from '@ngrx/signals';
import { SessionDetail, SessionsApi } from './sessions-api';

export const SessionDetailStore = signalStore(
  withState({ detail: null as SessionDetail | null }),
  withMethods((store, api = inject(SessionsApi)) => ({
    async open(id: string): Promise<void> {
      patchState(store, { detail: await api.getDetail(id) });
    },
  })),
  withHooks((store) => {
    const api = inject(SessionsApi);
    return {
      onDestroy() {
        api.releaseSeatHold(store.detail()?.id);
      },
    };
  })
);

@Component({ selector: 'app-session-detail', template: '...', providers: [SessionDetailStore] })
export class SessionDetailComponent {
  readonly id = input.required<string>();
  readonly store = inject(SessionDetailStore);
  ngOnInit(): void {
    this.store.open(this.id());
  }
}

go deeper

for a junior

Recall that a SignalStore must be provided, either with providedIn: 'root' or in a providers array, and that withHooks gives it onInit and onDestroy.

for a middle

Explain that onInit runs in the constructor on first injection, inside the injection context, and that onDestroy follows the destroying injector's DestroyRef.

for a senior

Show the lifetime consequences: root cleanup that never fires on navigation, per-component stores that reload on every visit, and the factory form for injected cleanup.

for a principal

Decide store placement by lifetime: state that must outlive a view goes to root, state that should die with a view goes to that view's providers.

## How a SignalStore instance comes to life `signalStore()` returns a class decorated as injectable with `providedIn` taken from its config object, or with no `providedIn` at all when you pass no config. When Angular creates an instance, the constructor: 1. applies every feature in order (`withState`, `withComputed`, `withMethods`, ...); 2. copies the resulting state signals, props and methods onto the instance; 3. calls the merged **`onInit`** hook, if any; 4. registers the merged **`onDestroy`** hook with `inject(DestroyRef)`, the destroy hook of the injector creating the instance. Because all of this happens in the constructor, `onInit` runs **in the injection context**. Inside it you can call `inject()`, start Angular's `effect()`, or pipe an observable through `takeUntilDestroyed()` without passing an injector. ## Where the store can be provided | Provision | How you write it | Instances | When `onInit` runs | When `onDestroy` runs | |---|---|---|---|---| | Root | `signalStore({ providedIn: 'root' }, ...)` | one for the app | first time anything injects it | when the root injector is destroyed, usually at app teardown | | Platform | `signalStore({ providedIn: 'platform' }, ...)` | one shared by the apps on the page | first injection | when the platform is destroyed | | Component | `providers: [SessionDetailStore]` on the component | one per component instance | when that component (or a child) first injects it | when that component is destroyed | | Route | `providers` on a route definition | one per route injector | first injection under that route | when that route's injector is destroyed | With **no config and no `providers` entry**, injecting the store fails with Angular's no-provider error: `signalStore()` does not register itself. The `'platform'` option was added in NgRx 20.1; `'root'` is the everyday global choice. ## The conference planner, both ways - `ScheduleStore` is provided at root. Its `onInit` loads the session list once, the first time the app injects the store. Navigating between pages does not re-run it, and its `onDestroy` effectively never runs while the app is open, so cleanup logic there is for tests and app teardown, not for navigation. - `SessionDetailStore` is listed in the session-detail component's `providers`. Opening a session creates the component and a fresh store; `onInit` loads that session's speakers and room. Closing the view destroys the component, its injector and the store, and `onDestroy` runs. Two detail panels open side by side get two independent stores. ## The two forms of `withHooks` - **Object form:** `withHooks({ onInit(store) { ... }, onDestroy(store) { ... } })`. Both hooks receive the store. `onInit` runs in the injection context; `onDestroy` does not, because it is called later by `DestroyRef`. - **Factory form:** `withHooks((store) => { const logger = inject(Logger); return { onInit() { ... }, onDestroy() { ... } }; })`. The factory runs in the injection context, so anything it injects is available to both hooks, and the two hooks can share local variables such as a timer id. Several `withHooks` features in one store are **merged**: the `onInit` from an earlier feature runs before the later one, and the same for `onDestroy`. Each hook also sees only the members declared before its own `withHooks`, so put it after the methods it calls. ## Loading data on init A common pattern is to call a store method from `onInit`: `withHooks({ onInit(store) { store.load(); } })`, with `withHooks` placed after the `withMethods` that defines `load`. Anything `onInit` starts in the injection context, such as an Angular `effect()` or a subscription piped through `takeUntilDestroyed()`, is tied to the same injector, so it stops when the store is destroyed. For a root store that means it runs for the whole session; for a component store it stops when the view closes. ## Pitfalls - Putting per-page cleanup in a root store's `onDestroy` and wondering why it never fires. - Loading data in a component-provided store's `onInit` and expecting it to be cached across visits: each new component gets a new store. - Calling `inject()` inside an object-form `onDestroy`: it runs outside the injection context and fails. Use the factory form. - Forgetting that an unprovided store is an error, not a silent root singleton. The question of how Angular's injector tree resolves providers in general belongs to Angular's dependency-injection topic; what is specific here is that the SignalStore's own hooks follow the lifetime of whichever injector created it.

  • Why can't the object form of a SignalStore's withHooks call inject() inside onDestroy, and what do you use instead?
    In the object form, `onInit` runs in the constructor, inside the injection context, but `onDestroy` is called later by `DestroyRef`, outside it, so `inject()` fails there. Use the factory form: the factory runs in the injection context, injects what both hooks need, and returns `{ onInit, onDestroy }` as closures over those values.
  • What happens when a SignalStore has two withHooks features?
    Both are kept. The store merges them, so the earlier feature's `onInit` runs first and then the later one, and the same order applies to `onDestroy`. Each hook only sees the members declared before its own `withHooks`, which is how a custom feature can add its own hooks without replacing the store's.

saying these in an interview costs you the question

  • signalStore() puts the store in the root injector unless you say otherwise.
  • onInit runs when the store file is imported, before anything injects it.
  • A root store's onDestroy runs when the user navigates away from the page using it.
  • Object-form onDestroy runs in the injection context, so inject() works there.
  • onInit runs outside DI, so effect() and takeUntilDestroyed() need an explicit Injector.