skip to content

With the NgRx SignalStore Events plugin, how do eventGroup, withReducer and withEventHandlers cooperate in a notifications flow, and in what order do they see an event?

level: seniorimportance: should knowfreq 21%

answer

  1. events describe what happened
  2. [Source] eventName type strings
  3. reducers patch, handlers do I/O
  4. ReducerEvents before Events
  5. returned events are dispatched

basics

~20 s

eventGroup defines events like [Notifications Page] opened; withReducer maps events to state changes; withEventHandlers reacts with side effects and returns new events, which are dispatched. Reducers see each event first, so handlers observe the updated state.

solid answer

~30 s

With `@ngrx/signals/events`, `eventGroup({ source: 'Notifications Page', events: { opened: type<void>() } })` gives creators whose type is `[Notifications Page] opened`. The store declares `withReducer(on(opened, () => ({ loading: true })), on(loaded, ({ payload }) => ({ items: payload, loading: false })))` for state changes, and `withEventHandlers` for effects: a handler listens with `inject(Events).on(opened)`, calls the API and uses `mapResponse` to return `loaded` or `loadFailed`, which is dispatched automatically. The `Dispatcher` feeds `ReducerEvents` first, so reducers apply before handlers react. Components dispatch with `injectDispatch(notificationsPageEvents)`. NgRx 21 renamed `withEffects` to `withEventHandlers`.

code

ts · 13 lines
ts
import { type } from '@ngrx/signals';
import { eventGroup } from '@ngrx/signals/events';
import { Notification } from './notification';

export const notificationsPageEvents = eventGroup({
  source: 'Notifications Page',
  events: { opened: type<void>(), markedRead: type<string>() },
});

export const notificationsApiEvents = eventGroup({
  source: 'Notifications API',
  events: { loaded: type<Notification[]>(), loadFailed: type<string>() },
});

go deeper

for a junior

Recall the three pieces: events created with eventGroup, withReducer for state changes, withEventHandlers for side effects, and dispatching through injectDispatch.

for a middle

Explain the [Source] eventName type format, what a case reducer may return, and that a handler's returned events are dispatched for you.

for a senior

Show production detail: reducers run before handlers, one escaped error stops all of a store's handlers, mapResponse keeps failures as events, and scopes limit reach.

for a principal

Weigh the plugin's decoupling against its indirection, and decide where a team uses events instead of direct store methods.

## The Events plugin in one paragraph `@ngrx/signals/events` adds an event-driven layer on top of SignalStore. Instead of components calling store methods, they **dispatch events** — plain objects with a `type` and an optional `payload` — and stores **react** to them: `withReducer` turns events into state transitions, and `withEventHandlers` runs side effects that may produce further events. The *what* (an event happened) is separated from the *how* (which state changes and which requests follow). ## The building blocks in a notifications flow 1. **Event creators.** `eventGroup({ source: 'Notifications Page', events: { opened: type<void>(), markedRead: type<string>() } })` creates one creator per key. Each type is `[Source] eventName`, with the key kept exactly as written: `notificationsPageEvents.opened()` returns `{ type: '[Notifications Page] opened' }`, and `markedRead('n-42')` also carries `payload: 'n-42'`. A second group, `notificationsApiEvents`, holds `loaded` and `loadFailed`. The single-event form is `event(type, payload?)`. 2. **State transitions.** `withReducer(on(notificationsPageEvents.opened, () => ({ loading: true })), on(notificationsApiEvents.loaded, ({ payload }) => ({ items: payload, loading: false })))`. Each case handler receives the event and the current state and returns a partial state, a partial state updater, or an array of them. This `on` is the one exported from `@ngrx/signals/events`. 3. **Side effects.** `withEventHandlers((store, events = inject(Events), api = inject(NotificationsApi)) => ({ load$: events.on(notificationsPageEvents.opened).pipe(exhaustMap(() => api.list().pipe(mapResponse({ next: notificationsApiEvents.loaded, error: ... })))) }))`. A handler is an observable; when it emits a newly created event, that event is **dispatched automatically**. 4. **Dispatching.** A component uses `injectDispatch(notificationsPageEvents)` and calls `this.dispatch.opened()`, or injects the `Dispatcher` service and calls `dispatch(notificationsPageEvents.opened())`. 5. **Reading.** Unchanged: components read `store.items()` and `store.loading()` as with any SignalStore. ## The order in which an event is seen When the `Dispatcher` dispatches an event, it first pushes it to the **`ReducerEvents`** stream, which `withReducer` listens on, and then to the **`Events`** stream, which handlers normally listen on. So for `opened`: 1. every `withReducer` case for `opened` runs and state is patched (`loading: true`); 2. then every `withEventHandlers` handler listening via `Events` reacts — here, the request starts; 3. when the request answers, the handler emits `loaded(items)`, which is dispatched and starts the cycle again: reducers apply `items`, then any handlers for `loaded` run. Handlers that listen via `Events` therefore see the state **after** the reducers for the same event. A handler that must itself act as a custom state transition can listen on `ReducerEvents` instead, so it runs alongside the reducers. An event that a handler merely passes through — the same object it received from `events.on(...)` — is **not** dispatched again, which prevents an accidental infinite loop. ## Errors and handler lifetime `withEventHandlers` subscribes all of a store's handlers as one merged subscription in the store's `onInit` hook, and ends it when the store is destroyed. There is no automatic resubscription: an error that escapes one handler ends that merged subscription, so **all** of that store's handlers stop reacting. Map failures into events on the inner request — `mapResponse({ next, error })` from `@ngrx/operators` returns an event for each case — so a failed request becomes `loadFailed(message)`, which a reducer can record. ## Scope and naming facts | Topic | What NgRx 22 does | |---|---| | Default scope | `Dispatcher` and `Events` are global: an event dispatched there reaches every store listening in that scope | | Local scope | `provideDispatcher()` in a component's `providers` creates a local scope | | Forwarding | `dispatch({ scope: 'parent' })` or `{ scope: 'global' }`; `toScope` / `mapToScope` in handlers | | Visibility | a local `Events` also receives parent and global events; ancestors do not see local ones | | History | `withEventHandlers` was named `withEffects` until NgRx 21, which renamed it; `withEffects` no longer exists | ## Dispatching from components `injectDispatch(notificationsPageEvents)` is called in an injection context, usually a field initializer, or given an `{ injector }` option. It returns an object that mirrors the group, so `this.dispatch.opened()` and `this.dispatch.markedRead(id)` create and dispatch in one step, with payload types checked. Calling `this.dispatch({ scope: 'parent' })` first returns the same object bound to that scope. The component never imports the store's reducers or handlers; it only knows the event group, which is exactly the decoupling the plugin exists for. ## What an interviewer is checking - Can you name the three pieces — event creators, `withReducer`, `withEventHandlers` — and say which one may perform I/O? Only handlers. - Do you know reducers run first, so handlers observe updated state? - Do you keep errors inside the inner request, returning a failure event rather than letting the stream die? - Do you use current names: `withEventHandlers`, not `withEffects`, and `on` from `@ngrx/signals/events` for these reducers?

  • How would a component-level notifications panel keep its events from reaching every other store in the app?
    Add `provideDispatcher()` to the component's `providers`. It creates local `Dispatcher` and `Events` instances, so events dispatched there stay in that scope by default. The local scope still receives parent and global events, and a specific event can be forwarded with `dispatch({ scope: 'parent' })` or `{ scope: 'global' }`, or with `toScope` and `mapToScope` inside handlers.
  • Why does a handler that pipes events.on(markedRead) through tap and emits the same event not loop forever?
    Events delivered by `Events.on()` are marked with their source type, and `withEventHandlers` only dispatches emitted events that do not carry that mark. Passing a received event through is therefore treated as observation, not as a new dispatch. Only newly created event objects, such as `api.loaded(items)`, are dispatched.

saying these in an interview costs you the question

  • withEventHandlers handlers see an event before the reducers update state
  • withEffects is still the NgRx 22 name for event handlers
  • A handler must inject Dispatcher to dispatch the events it returns
  • withReducer case handlers are a fine place to call the API
  • An error escaping one handler stops only that handler; the others keep running