NgRx Store Doesn't Have to Be hard - Think Events Not Messages
NgRx includes several independent packages like @ngrx/store
,
@ngrx/component-store
, and the latest addition, @ngrx/signals
. The most
complex one is @ngrx/store
, which is essentially an Angular-specific
implementation of the Redux pattern. While its concepts are not inherently
difficult to understand, many developers struggle with its mental model. If you
follow the NgRx documentation, most examples
adhere to a traditional object-oriented mindset. Let’s examine some sample code
provided by NgRx to elaborate on this point.
// src/app/counter.reducer.ts
const initialState = 0;
export const counterReducer = createReducer(
initialState,
on(increment, (state) => state + 1),
on(decrement, (state) => state - 1),
on(reset, (state) => 0)
);
// my-counter.component.ts
export class MyCounterComponent {
count$ = store.select("count");
constructor(private store: Store<{ count: number }>) {}
increment() {
this.store.dispatch(increment());
}
decrement() {
this.store.dispatch(decrement());
}
reset() {
this.store.dispatch(reset());
}
}
Despite introducing concepts like reducers, selectors, and actions, the mental model still closely resembles a object with some methods you can call on like below:
class Counter {
count = 0;
increment() {
this.count++;
}
decrement() {
this.count--;
}
reset() {
this.count = 0;
}
}
For many developers, dispatching an action feels very similar to calling a method on an object. This aligns with a message-driven architecture, where reducers and actions share a 1:1 mapping.
Message-Driven Architecture (MDA)
So what is Message-Driven Architecture, it has the following characteristics:
- Focuses on delivering messages to specific recipients.
- Components communicate by sending messages to known addresses.
- Emphasizes the “message” as a unit of communication and its “delivery” to a destination.
- Often uses message queues or brokers for reliable delivery.
- Think of it like mailing a letter to a specific address.
The counter example can be best implemented with a message-driven architecture,
and NgRx offers @ngrx/component-store
for this purpose. With Component Store,
you don’t need to dispatch an action; you simply call a method on the object.
The method name serves as the action, and the method body acts as the handler,
as shown in the following code.
interface CounterState {
count: number;
}
@Injectable({
providedIn: "root",
})
export class CounterStore extends ComponentStore<CounterState> {
constructor() {
super({ count: 0 }); // Initial state
}
count$ = this.select((state) => state.count);
increment = this.updater((state) => ({
count: state.count + 1,
}));
decrement = this.updater((state) => ({
count: state.count - 1,
}));
reset = this.updater(() => ({
count: 0,
}));
}
export class MyCounterComponent {
count$ = this.store.count$.
constructor(private store: CounterStore) {
}
increment() {
this.store.increment();
}
decrement() {
this.store.decrement();
}
reset() {
this.store.reset();
}
}
The implementation of the counter example using NgRx Store feels awkward because it is based on Event-Driven Architecture. This architecture excels in more complex front-end applications but requires adopting a new mindset.
Event-Driven Architecture (EDA)
It has the following charateristics:
- Focuses on the occurrence of “events” and reactions to those events.
- Components emit events, and other components “subscribe” to those events to react accordingly.
- Emphasizes “events” as state change signals and their asynchronous nature.
- Promotes loose coupling—components don’t need to know specific recipients.
- Think of it like a public announcement that anyone can listen to.
The key difference between messages and events:
- Messages are sent to specific destinations.
- Events are broadcasted and can be handled by any interested subscriber.
NgRx is fundamentally designed for Event-Driven Architecture, but its terminology is highly technical, which makes EDA less obvious. As a result, many developers misuse it with an message-driven mindset.
A New Mental Model of NgRx Store
To fully embrace this event-driven architecture, we need to establish a new mental model that aligns better with its principles. Let’s start by mapping some NgRx terminology to event-driven terminology.
NgRx Terminology | Event-Driven Terminology (proprosed by me😀 ) |
---|---|
Actions | Events |
Reducers | State Handlers (Subscribers to Events) |
Effects | Resource Handlers (Subscribers to Events) |
Dispatch Actions to store | Dispatch Events to Event Bus |
Selectors | Data Views |
The term Action
implies an intention to perform a task, which aligns with a
message-driven mindset. Conversely, event
suggests a more reactive approach.
The terms reducer
and effect
are quite technical, whereas state handler
and resource handler
clearly define their roles within an event-driven
architecture. In NgRx, we use store.dispatch(action)
, which might suggest that
actions are exclusively handled by reducers. However, actions can also be
processed by effects. Although the concept of an event bus
does not exist in
NgRx, I use it to illustrate that events are shared and can be handled by both
reducers and effects. The term selector
is also too technical, but Data view
indicates that it is a derived view from the actual data.
NgRx model
Mental Model of NgRx
In the mental model, we can see,
- Component publish events to event bus.
- Component subscribe the change of the of data through data view.
- State handler subscribe events and update store.
- Resource handler subscribe events, exchange information from external world using ajax, websocket, etc, and optionally publish one event back to event bus.
- Publisher and subscirber does not know each other.
The seperation of handler(reducer/effect) and event is A game changing feature. One handler can handle many events, and one event can be handled by many handlers. While their relation in message-driven architecture is one-to-one.
Event, not action
Events are central to the architecture, with everything revolving around them. The first step to adopting this mental model is to rename your actions to events.
Old action naming convention:
- It is organized by target (subscriber)
target.doSomethingToTarget
, such asuserActions.setUser
.
New action naming convention:
- It is organized by source (publisher),
source.whatHappend
, for exampleuserPageEvents.entered
.
A new way of thinking
If you are working on a component, instead of act like an adult issuing order “You, give me food”, act like a crying baby “Wah! Wah! Wah!”.
If you are working on a reducer, shift your focus from providing data to changing yourself in response to an event.
If you are working on an effect, consider yourself a converter that takes an input event, processes it, and returns a new event.
Now, let’s examine some problems caused by a message-driven mindset and how an event-driven mindset can resolve them.
Problem 1: Dispatching Multiple Actions Sequentially
It’s common to see developers dispatch multiple actions sequentially in a component, like the following code.
// user.page.component.ts
ngOnInit() {
// 👉 dispatch three acitons
this.store.dispatch(apiActions.loadUser());
this.store.dispatch(apiActions.loadOrders());
this.store.dispatch(apiActions.loadCart());
}
// effect.ts
loadUser$ = createEffect(() =>
this.actions$.pipe(
ofType(apiActions.loadUser),
switchMap(() =>
this.userService.getUser().pipe(
map(user => userActions.setUser({ user })),
catchError(error => of(({ error })))
)
)
)
);
loadUser$ = createEffect(() =>
this.actions$.pipe(
ofType(apiActions.loadOrders),
switchMap(() =>
this.userService.getOrders().pipe(
map(user => orderActions.setOrder({ order })),
catchError(error => of(({ error })))
)
)
)
);
loadUser$ = createEffect(() =>
this.actions$.pipe(
ofType(apiActions.loadCart),
switchMap(() =>
this.userService.getOrders().pipe(
map(user => orderActions.setOrder({ order })),
catchError(error => of(({ error })))
)
)
)
);
// reducer.ts
.on(userActions.setUser, /*...*/)
.on(orderActions.setOrders, /*...*/)
.on(cartActions.setCard, /*...*/)
Sequentially dispatching actions can lead to unexpected intermediate states and
unnecessary event loop cycles. This problem arises from a message-driven
mindset, where we assume we are sending a message to a specific destination,
like “Hey, API, please load the user for me.” In the case here, the developer
is thinking to issuing multiple commands to a service. If he use the
event-driven mindset, he will assume his role is the userPage
broadcasting
what has been happend to it, the page is entered
. And we can some up with
the following solution.
Solution: Dispatch a Single Event Handled by Multiple Subscribers
// user.page.ts
ngOnInit() {
// The event is "Hi there, I, the userPage is entered"
this.store.dispatch(userPageEvents.entered());
}
// effects.ts
loadUser$ = createEffect(() =>
this.actions$.pipe(
ofType(userPageEvents.enterPage), // 👈 same events trigger multiple effects
switchMap(() =>
this.userService.getUser().pipe(
map(user => apiEvent.userLoaded({ user })),
catchError(error => of(loadUserFailure({ error })))
)
)
)
)
);
loadOrders$ = createEffect(() =>
this.actions$.pipe(
ofType(userPageEvents.enterPage),// 👈 same events trigger multiple effects
switchMap(() =>
this.userService.getOrder().pipe(
map(user => apiEvent.orderLoaded({ user })),
catchError(error => of(loadUserFailure({ error })))
)
)
)
)
);
loadCart$ = createEffect(() =>
this.actions$.pipe(
ofType(userPageEvents.enterPage),// 👈 same events trigger multiple effects
switchMap(() =>
this.userService.getCart().pipe(
map(user => apiEvent.cartLoaded({ user })),
catchError(error => of(loadUserFailure({ error })))
)
)
)
)
);
// skip reducer.ts
Problem 2: A reducer/effect is triggered everywhere, but I don’t know where it is triggered.
Another frequent complaint about NgRx is the difficulty in tracing the origin of actions, like the following code:
// effect.ts
escalatePermssion$ = createEffect(() =>
this.actions$.pipe(
ofType(actions.escalatePermssion),
//...
)
);
// user.component.ts
changeUserId() {
// trigger the same action
this.store.dispatch(actions.escalatePermssion())
}
// admin.component.ts
deleteAccount() {
// trigger the same action
this.store.dispatch(actions.escalatePermssion())
}
This issue arises because reducers and effects often have a 1:1 mapping with actions, leading to the same action being triggered in multiple places. With a message-driven mindset, we tend to treat our effects/reducers as APIs, sending the same command from different components. In contrast, with an event-driven mindset, each event is unique and tied to the specific place where it is triggered. We dispatch different events from different places, avoiding the reuse of the same event.
Solution: Dispatch unique events
escalatePermssion$ = createEffect(() =>
this.actions$.pipe(
// 👇 this effect handle different types of event
ofType(usePage.changingUserId, admin.deletingAccount),
//...
)
);
// user.component.ts
changeUserId() {
// 👇 trigger a unique events
this.store.dispatch(userPage.changingUserId())
}
// admin.component.ts
deleteAccount() {
// 👇 trigger a unique events
this.store.dispatch(adminPage.deletingAccount())
}
This change allows us to easily identify where an effect/reducer is triggered during development. In runtime, we can use the Chrome Redux extension to see a unique action being triggered, which aids in debugging.
Problem 3: Dispatch action recursively
If you are using message-driven mindset, You could write the following code
// productList.component.ts
select(productId) {
this.store.dispatch(productActions.selectProduct({productId}));
}
// reducer.ts
on(productActions.selectProduct, (state, actions) => {
return {...state, productId: actions.productId};
})
// relatedProducts.component.ts
relatedProducts$ = this.store.select(relatedProductSelector);
ngOnInit() {
// 👇 dispatch action when data change, don't do this
tis.store.select(selectedProductId).subscribe((productId) =>{
this.store.dispatch(apiActions.loadRelatedProduct({productId}));
})
}
//effects.ts
loadRelatedProduct$ = createEffect(() => {
this.actions$.pipe(
ofType(apiActions.loadRelatedProduct),
//...
});
NgRx follows a strict pattern: Actions -> Reducers -> Store -> UI.
Dispatching an action inside a selector subscription can lead to recursive data
updates, potentially causing an infinite loop and breaking this unidirectional
data flow. The root cause is that the initial action handler does not perform
all the necessary work, requiring compensation by reacting to the previous state
change. This issue can be traced back to a message-driven mindset. The intention
of the first action is to select a product, and the handler does what it is
asked to do—select a product—but it does not ask to load related products. By
triggering an event like productSelected
, you can have a state handler update
the selected product and an effect handler load related products, as shown in
the following code.
// productList.component.ts
select(productId) {
this.store.dispatch(productPageEvents.productSelected({productId}));
}
// reducer.ts
on(productPageEvents.productSelected, (state, actions) => {
return {...state, productId: actions.productId};
})
// relatedProducts.component.ts
relatedProducts$ = this.store.select(relatedProductSelector);
//effects.ts
loadRelatedProduct$ = createEffect(() => {
this.actions$.pipe(
ofType(productPageEvents.productSelected),
//...
});
Do I have to use Event-Driven Mindset?
If you choose to use @ngrx/store
, it’s essential to embrace an event-driven
mindset. This shift from a message-driven approach to separating events and
handlers is crucial. Without adopting this mindset, you may struggle to fully
leverage the benefits of the Event-Driven Architecture provided by @ngrx/store
.
Your state management code may not scale effectively, becoming difficult to
maintain and leading to ongoing issues with data synchronization and increased
debugging efforts.
However, many developers, including myself, are more comfortable with a
message-driven mindset, where control is more explicit. If you prefer this
approach, consider using libraries like @ngrx/component-store
,
@ngrx/signals
, or akita
. These libraries are powerful and offer the
advantage of minimal boilerplate—no need for actions, reducers, or effects, just
method calls. The key takeaway is to avoid using @ngrx/store
with a
message-driven mindset.
Conclusion
In summary, while NgRx Store can initially seem complex, adopting an event-driven mindset can simplify its usage and make it more intuitive. By thinking in terms of events rather than messages, you can leverage the full power of NgRx to create scalable and maintainable applications. Remember to rename your actions to events, focus on broadcasting events rather than sending messages, and let your reducers and effects handle these events independently. This approach not only aligns with the core principles of NgRx but also promotes a more decoupled and reactive architecture. Happy coding!