In NgRx 22, what does store.dispatch(() => genreChanged({ genre: this.genre() })) do, and how is that reactive dispatch cleaned up?
answer
- an Angular effect under the hood
- initially, then on each change
- only reads inside the function count
- injector decides the lifetime
- EffectRef.destroy()
basics
~20 sPassing 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 sSince 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 linesimport { 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
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.
Explain that NgRx wraps the function in an Angular effect, which reads are tracked, and that the call returns an EffectRef.
Show the injector order, the ngOnInit leak it causes, and the two fixes: pass the component's injector, or destroy the EffectRef.
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.