The new Event API brings the functionality familiar from the Redux-based NgRx "Global" Store to the world of the Signal Store. Since Redux implies a single central store, the NgRx team uses the more general term Flux.
One of the great strengths of this new API is that it can be used specifically for selected cases. This allows us to get started in a lightweight way and to add the Flux pattern to individual stores as needed.
In this article, I'll show you how to use this new API.
📂 Example Code
🔀 Branches: 10b-first-reducer and 10d-redux
Eventing Between Stores
To illustrate the potential for a targeted use of the Event API, I'll first demonstrate establishing a loose coupling between two stores. Here, the focus is on eventing itself rather than on implementing Flux. After that, I'll discuss how to implement Flux for a selected store.
The example used consists of three stores: The DessertStore manages the loaded list of desserts, and the DessertDetailStore is used to process a selected dessert. Furthermore, the ratings of the individual desserts are located in a RatingStore:
Such a split is not uncommon in lightweight stores like the Signal Store. Unlike Redux, the split is not based on technical aspects such as reducers, effects, or selectors. Instead, a small portion of the state is encapsulated along with the calculations and operations that rely on it.
The managed state often corresponds to an entity from the perspective of a (sub)feature. In the example considered here, this division means that a dessert updated by the DessertDetailStore may also need to be updated in the DessertStore to keep the loaded overview up to date.
To prevent both stores from being coupled to each other, an event indicating the update of a dessert is a good option. Such events can be defined using the Event API's event function. However, it is more common to provide several related events within an Event Group:
import { type } from '@ngrx/signals';
import { eventGroup } from '@ngrx/signals/events';
import { Dessert } from './dessert';
export const dessertDetailStoreEvents = eventGroup({
source: 'Dessert Detail Store',
events: {
dessertUpdated: type<{
dessert: Dessert
}>(),
},
});
All events in a group have the same source. The source informs about the part of the application that is triggering the event. This makes message flows easier to understand during debugging. Each event is defined by a name (here: dessertUpdate) and a type for its payload. This payload contains data that further describes the event.
The DessertDetailStore triggers the dessertUpdate event after saving a dessert. To do so, it uses the Dispatcher service provided by the Eventing API:
import { Dispatcher } from '@ngrx/signals/events';
[...]
export const DessertDetailStore = signalStore(
[...]
withProps(() => ({
[...],
_dispatcher: inject(Dispatcher)
})),
withMethods((store) => ({
[...],
save(id: number, dessert: Partial<Dessert>): void {
[...]
// Trigger event
const event = dessertDetailStoreEvents.dessertUpdated({
dessert: savedDessert
});
store._dispatcher.dispatch(event);
},
})),
);
The Reducer acts as the receiver in the DessertStore. It receives the event payload and returns an Updater:
import { on, withReducer } from '@ngrx/signals/events';
[…]
export const DessertStore = signalStore(
{ providedIn: 'root' },
withState({
[…]
desserts: [] as Dessert[],
}),
withReducer(
on(dessertDetailStoreEvents.dessertUpdated, ({ payload }) => {
const updated = payload.dessert;
return (store) => ({
desserts: store.desserts.map((d) =>
d.id === updated.id ? updated : d,
),
});
}),
),
[…]
)
The updater exchanges the modified dessert in the list with all retrieved desserts. The returned state is partial which means it only needs to contain the changed properties. As is typical with the Signal Store, Updaters can also be put to their own functions:
export type DessertSlice = {
desserts: Dessert[];
};
function updateDessert(updated: Dessert) {
return (store: DessertSlice) => ({
desserts: store.desserts.map((d) =>
(d.id === updated.id ? updated : d)),
});
}
Such an Updater function allows shortening the reducer:
withReducer(
on(dessertDetailStoreEvents.dessertUpdated, ({ payload }) => {
return updateDessert(updated);
}),
),
This possibility is also used to delegate to Updaters provided by custom features like withEntity. If the current state is not required, the reducer can directly provide an updated partial state. I'll discuss this option further below.
More on this: Angular Architecture Workshop (Remote, Interactive, Advanced)
Become an expert for enterprise-scale and maintainable Angular applications with our Angular Architecture workshop!
English Version | German Version
Flux/"Redux" for Individual Stores
In the next step, I would like to show how the Event API can be used to implement the Flux pattern for individual stores. This is similar to the use of the Redux-based NgRx “Global” Store:
- The consumer triggers an Event (in the “Global” Store this is called an Action).
- Effects receive Events and trigger asynchronous side effects, publishing the results as further Events.
- Reducers in the store receive Events and update the managed state.
Again, the Event API and Flux can be applied selectively and doesn't have to be imposed on all stores. This is particularly interesting when multiple components consume and modify the same state.
As before, the implementation starts with an Event Group. It contains a loadDesserts event that calls an Effect to load desserts. This effect then reports the outcome of this operation with the loadDessertsSuccess event or, in case of an error, with loadDessertsError:
import { eventGroup } from '@ngrx/signals/events';
import { type } from '@ngrx/signals';
import { Dessert } from './dessert';
export const dessertEvents = eventGroup({
source: 'Dessert Feature',
events: {
loadDesserts: type<{
originalName: string,
englishName: string,
}>(),
loadDessertsSuccess: type<{
desserts: Dessert[]
}>(),
loadDessertsError: type<{
error: string
}>(),
},
});
Reducer
The reducers for loading desserts are somewhat simpler than in the first case. Instead of an updater that maps the current state to the new one, they simply return the new values:
import { on, withReducer } from '@ngrx/signals/events';
[…]
export const DessertStore = signalStore(
{ providedIn: 'root' },
withState({
filter: {
originalName: '',
englishName: '',
},
loading: false,
desserts: [] as Dessert[],
error: '',
}),
withReducer(
[…],
on(dessertEvents.loadDesserts, ({ payload }) => {
return {
filter: payload,
loading: true,
};
}),
on(dessertEvents.loadDessertsSuccess, ({ payload }) => {
return {
desserts: payload.desserts,
loading: false,
};
}),
on(dessertEvents.loadDessertsError, ({ payload }) => {
return {
error: payload.error,
loading: false,
};
}),
),
[…]
);
Effects
The Effects in the Event API, which have nothing to do with the concept of the same name in the Signals world, are technically RxJS-based pipes that map incoming events to outgoing events. To do so, they typically include an asynchronous operation via a flattening operator such as switchMap or mergeMap:
import { Events, withEffects } from '@ngrx/signals/events';
import { mapResponse } from '@ngrx/operators';
[…]
export const DessertStore = signalStore(
[…],
withProps(() => ({
_dessertService: inject(DessertService),
_toastService: inject(ToastService),
_events: inject(Events),
})),
[…]
withEffects((store) => ({
loadDesserts$: store._events.on(dessertEvents.loadDesserts).pipe(
switchMap((e) =>
store._dessertService.find(e.payload).pipe(
mapResponse({
next: (desserts) => dessertEvents.loadDessertsSuccess({ desserts }),
error: (error) =>
dessertEvents.loadDessertsError({ error: String(error) }),
}),
),
),
),
})),
);
Even though a descriptive name, such as loadDesserts$ in the example shown, is important for readability, it is technically irrelevant because the Event API triggers the Effect not via its name, but via events. The store creates Effects via the Event service. Its on method filters the events received and returns them via an Observable. If the store passes no parameters to on, it receives all events. If the store passes one or more event types, on only returns the matching events.
To map the result of the triggered side effect to an outgoing event, the mapResponse operator supplied with NgRx is useful. It is a combination of map and catchError and allows for the transformation of results as well as errors.
As in the early days of the NgRx Global Store, it is essential that errors are handled, e.g., with catchError or, as shown, with mapResponse. Otherwise, RxJS closes the observable, and further events are no longer handled by the Effect. As usual, these errors must be handled at the level where they occurred. In the case shown, this is within switchMap, which leads to further nesting.
An option like resubscribeOnError in the Global Store, which automatically compensates for errors, doesn't currently exist. However, the NgRx team is considering it.
Consuming the Store
To consume the store, the individual components can get the Dispatcher via injection and use it to trigger the desired event:
this.#dispatcher.dispatch(
dessertEvents.loadDesserts({
originalName: this.originalName(),
englishName: this.englishName(),
}),
);
Bonus: Redux DevTools
The Signal Store does not come with an official integration with Redux Dev Tools, which allows for a view into the store during debugging and also provides information about the history of individual state changes. However, this capability can be added using the Signal Store's extensibility mechanism.
An implementation of this idea can be found in the community package @angular-architects/ngrx-toolkit. It provides a feature called withDevtools that can bei added to the store:
import { withDevtools } from '@angular-architects/ngrx-toolkit';
[…]
export const DessertStore = signalStore(
[…]
withDevtools('DessertStore')
);
The passed string is used as the name for the node that represents the respective store in the Dev Tools. If the Dev Tools were installed as a browser extension, the data stored in the store and its history can be viewed in the Developer Console:
Summary
The new Event API for the NgRx Signal Store allows for a targeted implementation of Flux patterns as needed. With additional tools such as the Redux DevTools simplifies debugging. The Event API provides a flexible bridge between a lightweight use of the Signal Store and proven state management practices known from the Redux world.