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

Imgur

Mental Model of NgRx

Imgur

In the mental model, we can see,

  1. Component publish events to event bus.
  2. Component subscribe the change of the of data through data view.
  3. State handler subscribe events and update store.
  4. Resource handler subscribe events, exchange information from external world using ajax, websocket, etc, and optionally publish one event back to event bus.
  5. 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 as userActions.setUser.

New action naming convention:

  • It is organized by source (publisher),
  • source.whatHappend, for example userPageEvents.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!