skip to content

In NgRx 22, what does store.dispatch(() => genreChanged({ genre: this.genre() })) do, and how is that reactive dispatch cleaned up?

level: seniorimportance: nice to knowfreq 24%

answer

  1. an Angular effect under the hood
  2. initially, then on each change
  3. only reads inside the function count
  4. injector decides the lifetime
  5. EffectRef.destroy()

basics

~20 s

Passing a function to Store.dispatch creates an Angular effect that dispatches the returned action initially and again whenever a signal read inside it changes. It returns an EffectRef, and ends with its injector: the caller's, a passed one, or the root.

solid answer

~30 s

Since NgRx 19, `dispatch` accepts a function returning an action. NgRx wraps it in an Angular `effect()`: it runs once, dispatches the action inside `untracked`, and runs again whenever a signal read inside the function changes, here `genre`. It returns an `EffectRef`. The lifetime comes from the injector: a passed `{ injector }` first, then the caller's injection context, so a constructor call ends with the component, and otherwise the Store's root injector. Calling it in `ngOnInit` without options therefore leaks an effect per component instance; pass `inject(Injector)` or call `destroy()` on the `EffectRef` in `ngOnDestroy`.

code

ts · 28 lines
ts
import { Component, EffectRef, Injector, OnDestroy, OnInit, inject, input } from '@angular/core';
import { Store } from '@ngrx/store';
import { CataloguePageActions } from './catalogue-page.actions';

@Component({ selector: 'app-catalogue', template: '' })
export class CatalogueComponent implements OnInit, OnDestroy {
  readonly genre = input.required<string>();
  private readonly store = inject(Store);
  private readonly injector = inject(Injector);
  private genreRef?: EffectRef;

  ngOnInit(): void {
    // Outside the injection context: pass the component's injector.
    this.store.dispatch(
      () => CataloguePageActions.genreChanged({ genre: this.genre() }),
      { injector: this.injector }
    );

    // Or keep the EffectRef and destroy it yourself.
    this.genreRef = this.store.dispatch(() =>
      CataloguePageActions.opened()
    );
  }

  ngOnDestroy(): void {
    this.genreRef?.destroy();
  }
}

go deeper

for a junior

Recall that NgRx's dispatch can take a function returning an action, and that it re-dispatches when a signal read in that function changes.

for a middle

Explain that NgRx wraps the function in an Angular effect, which reads are tracked, and that the call returns an EffectRef.

for a senior

Show the injector order, the ngOnInit leak it causes, and the two fixes: pass the component's injector, or destroy the EffectRef.

for a principal

Decide where signal-driven dispatch fits next to explicit event handlers, so the action log still reads as user and API events.

## Dispatch that follows a signal Since NgRx 19, `Store.dispatch` has two overloads: - `dispatch(action)`, which sends one action object and returns `void`; - `dispatch(() => action, config?)`, which takes a **function that returns an action** and returns an Angular `EffectRef`. The second form exists for a common case: an action whose payload comes from a signal, such as a component input. In a bookstore catalogue, the genre comes from the route as an input, and the store must hear about it every time it changes: ```ts import { Component, inject, input } from '@angular/core'; import { Store } from '@ngrx/store'; import { CataloguePageActions } from './catalogue-page.actions'; @Component({ selector: 'app-catalogue', template: '' }) export class CatalogueComponent { readonly genre = input.required<string>(); private readonly store = inject(Store); constructor() { this.store.dispatch(() => CataloguePageActions.genreChanged({ genre: this.genre() }) ); } } ``` ## How it works NgRx wraps the function in an Angular `effect()`: 1. The effect runs the function, which reads `this.genre()`. Angular records that read as a dependency. 2. NgRx dispatches the returned action inside `untracked`, so nothing the store reads while handling it becomes a dependency of this effect. 3. When `genre` changes, the effect runs again and dispatches a fresh action. The docs summarise it as: `dispatch` executes initially and every time the signal changes. Only signals read **inside** the function are tracked. Reading the value first and closing over it gives a dispatch that never repeats: ```ts const genre = this.genre(); // read outside this.store.dispatch(() => CataloguePageActions.genreChanged({ genre })); ``` ## Lifetime: which injector owns the effect An Angular effect lives as long as the injector it was created in. NgRx picks that injector in this order: | Where `dispatch(fn)` is called | Injector used | The effect ends when | |---|---|---| | with `{ injector }` passed | the one you pass | that injector is destroyed | | in an injection context (constructor, field initializer) | the caller's injector | the component is destroyed | | outside one (for example `ngOnInit`), no config | the `Store`'s own injector, the root | the application ends, or you call `destroy()` | The third row is the trap. Moving the call from the constructor to `ngOnInit` looks harmless, but the effect is now tied to the root injector. Every time the catalogue component is created and destroyed, another effect survives, still tracking a destroyed component's input and still dispatching. The NgRx docs give the two fixes: - pass the component's injector: `this.store.dispatch(fn, { injector: this.injector })`, with `injector = inject(Injector)`; - keep the returned `EffectRef` and call `destroy()` on it in `ngOnDestroy`. ## When to use it, and when not Use it for **"tell the store whenever this value changes"**: a route input, a selected tab, a search term held in a signal. It removes a hand-written Angular `effect()` that only dispatches. Be careful in these cases: - **Events the user performs.** A click is a one-off; `dispatch(action)` in the handler is clearer than a signal that exists only to trigger a dispatch. - **Repeated values.** The dispatch happens when the signals it reads notify a change. A signal that is set to an equal value does not notify, so a "reload the same genre" intent needs its own event. - **Action naming.** Every re-run appears in the DevTools log. `[Catalogue Page] Genre Changed` reads as a sequence of reader choices; a command such as `loadBooks` repeated per signal change does not. ## The version point This overload arrived in NgRx 19. On older versions the same effect had to be written by hand with Angular's `effect()` and an `untracked` dispatch. On NgRx 22 the built-in form is the one to know, including its return type: the `EffectRef` is how you stop it. ## Testing it Because the dispatch runs through an Angular effect, a unit test must let Angular run its scheduled effects before asserting on dispatched actions, for example with `TestBed.tick()` after changing the input. A test that sets the input and immediately checks the dispatched actions can miss the re-run and pass or fail for the wrong reason. A senior answer covers three things: the re-run rule (initially, then on every change of a signal read inside the function), the injector order, and the `ngOnInit` leak with its two fixes.

  • Why does const g = this.genre(); store.dispatch(() => genreChanged({ genre: g })) dispatch only once?
    Only signal reads that happen inside the function, while the effect runs, are tracked. Here the signal is read before `dispatch` is called and the function closes over a plain string, so the effect has no dependencies and never re-runs. Move the `this.genre()` call inside the function.
  • Why does NgRx dispatch the action inside untracked?
    Dispatching runs reducers and whatever reacts to the new state, and some of that may read signals. Wrapping the dispatch in `untracked` keeps those reads from becoming dependencies of this effect, so it re-runs only when the signals read by the action-building function change, not when some store internals do.

saying these in an interview costs you the question

  • dispatch with a function sends the action once, like dispatch with an object.
  • The reactive dispatch always ends when the component that called it is destroyed.
  • Any signal read anywhere in the component retriggers the dispatch.
  • It returns a Subscription, so you clean up with unsubscribe().
  • It must be called in an injection context or it throws.