Think in Events: A Better Mental Model for NgRx Store
Many developers find @ngrx/store harder than expected, even though its
individual APIs are not especially complicated. The challenge is understanding
why NgRx Store separates publishers from handlers and how that separation should
influence the way we design actions.
Most NgRx introductions, including the official Store walkthrough, begin with a counter. It is an effective way to demonstrate actions, reducers, selectors, and dispatching with very little code:
// 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, () => 0),
);
// my-counter.component.ts
export class MyCounterComponent {
private readonly store = inject(Store<{ count: number }>);
readonly count$ = this.store.select('count');
increment() {
this.store.dispatch(increment());
}
decrement() {
this.store.dispatch(decrement());
}
reset() {
this.store.dispatch(reset());
}
}
The mechanics are easy to see, but so is a familiar object-oriented model. The
component dispatches increment, decrement, and reset, which look much like
methods on a counter object:
class Counter {
count = 0;
increment() {
this.count++;
}
decrement() {
this.count--;
}
reset() {
this.count = 0;
}
}
These examples describe almost the same behavior. That makes the counter useful for teaching syntax but less useful for explaining NgRx’s architecture: it has one clear owner and no meaningful need for independent handlers. The example is not incorrect, but it can establish a command-driven mental model in which every action looks like an indirect method call.
In a larger application, that model can make actions, reducers, and effects feel like ceremony. The central question is whether an action tells a handler what to do or publishes what happened so independent handlers can decide how to react.
Events vs Commands
In software communication, we often use the word “message” as if it were one thing. In practice, it usually means one of two different intents: a command or an event.
Both can reduce coupling, but they optimize for different outcomes.
| Feature | Commands | Events |
|---|---|---|
| Primary intent | Tell a specific handler what to do | Announce that something already happened |
| Coupling style | Caller chooses the target operation | Publisher does not know who listens |
| Naming style | Imperative verbs, often present tense | Facts, often past tense |
| Fan-out | Usually one command to one handler | One event can trigger many handlers |
In short, a command means “do this”, while an event means “this happened”. The counter actions are mostly commands. NgRx supports that style, but its separation pays off when actions describe events that reducers and effects can handle independently.
An Event-Driven Mental Model for NgRx Store
In NgRx, commands and events use the same technical construct: an action. The
framework does not determine the intent; the way we name and publish the action
does. For event-driven design, use this practical interpretation:
| NgRx Term | Event-Driven Interpretation |
|---|---|
| Actions | Events |
| Reducers | Event-to-State Transitions |
| Effects | Event Reactors (I/O + New Events) |
| Dispatch | Publish Event |
| Selectors | State Projections / Read Models |
This is a design lens, not a framework restriction. Actions can still express commands, but event-driven naming makes the publisher–handler separation explicit.
The event stream in this diagram represents NgRx’s Actions stream. Events
flow from publishers to reducers and effects, while selected state flows back to
components as an Observable or Signal.

For an event-driven design, name actions from the publisher’s context—for example,
userPageEvents.userSubmitted—rather than after a target operation such as
userActions.setUser. The following examples show how this change resolves
common NgRx problems.
Problem 1: Dispatching Multiple Actions Sequentially
This violates NgRx’s
avoid-dispatching-multiple-actions-sequentially
ESLint rule.
An action should be an event that abstracts away the details of store internals.
Incorrect:
export class Component implements OnInit {
constructor(private readonly store: Store) {}
ngOnInit() {
// ⚠ multiple actions dispatched
this.store.dispatch(loadEmployeeList());
this.store.dispatch(loadCompanyList());
this.store.dispatch(cleanData());
}
}
Correct:
// in component code:
export class Component implements OnInit {
constructor(private readonly store: Store) {}
ngOnInit() {
this.store.dispatch(componentLoaded());
}
}
// in effects:
export class Effects {
loadEmployeeList$ = createEffect(() => this.actions.pipe(
ofType(componentLoaded),
exhaustMap(() => this.dataService.loadEmployeeList().pipe(
map(response => loadEmployeeListSuccess(response)),
catchError(error => loadEmployeeListError(error)),
)),
));
loadCompanyList$ = createEffect(() => this.actions.pipe(
ofType(componentLoaded),
// handle loadCompanyList
));
cleanData$ = createEffect(() => this.actions.pipe(
ofType(componentLoaded),
// handle cleanData
));
constructor(
private readonly actions$: Actions,
) {}
}
Command-driven thinking makes the component orchestrate three operations: load
employees, load companies, and clean stale data. With event-driven thinking, it
publishes only componentLoaded; reducers and effects independently decide how
to react. The workflow no longer belongs to the component.
Problem 2: A Generic Action Hides Why It Was Dispatched
Another frequent complaint about NgRx is that an action can be difficult to trace back to the interaction that caused it. Consider an effect that handles the same action dispatched by two unrelated components:
// effect.ts
escalatePermission$ = createEffect(() =>
this.actions$.pipe(
ofType(actions.escalatePermission),
tap(() => this.permissionService.escalate())
),
{ dispatch: false }
);
// user.component.ts
changeUserId() {
// trigger the same action
this.store.dispatch(actions.escalatePermission());
}
// admin.component.ts
deleteAccount() {
// trigger the same action
this.store.dispatch(actions.escalatePermission());
}
Why is the same generic action dispatched from unrelated places? Because
escalatePermission is treated as a command. It is named from the handler’s
perspective and tells the effect what to do, so any component that needs that
operation naturally reuses the same command. As a result, the action history
records the requested operation but loses the reason it was requested.
With event-driven thinking, each interaction publishes a distinct,
source-specific event. userIdChangeRequested and accountDeletionRequested
describe different facts, even if they happen to require the same reaction. The
effect can observe both events without the components knowing how it will
respond:
escalatePermission$ = createEffect(() =>
this.actions$.pipe(
ofType(
userPageActions.userIdChangeRequested,
adminPageActions.accountDeletionRequested,
),
tap(() => this.permissionService.escalate())
),
{ dispatch: false }
);
// user.component.ts
changeUserId() {
this.store.dispatch(userPageActions.userIdChangeRequested());
}
// admin.component.ts
deleteAccount() {
this.store.dispatch(adminPageActions.accountDeletionRequested());
}
The handling logic is still shared, but the publishers no longer reuse a generic command. Each action identifies the interaction that caused the effect to run, so the action history preserves the reason for the reaction. This is an architectural guideline rather than a violation the NgRx linter can reliably detect.
Problem 3: Command Thinking Leads to Manual Store Subscriptions
With command-driven thinking, components issue instructions and coordinate when each instruction should run. In the following example, the product-list component commands the application to switch products. The related-products component then observes that state change, dispatches another command to load data, and manually copies the result into a local property:
// product-list.component.ts
select(productId) {
this.store.dispatch(productActions.switchProduct({ productId }));
}
// reducer.ts
on(productActions.switchProduct, (state, { productId }) => ({
...state,
currentProductId: productId,
}));
// effects.ts
loadRelatedProducts$ = createEffect(() =>
this.actions$.pipe(
ofType(productActions.loadRelatedProducts),
// handle loadRelatedProducts
)
);
// related-products.component.ts
relatedProducts: Product[] = [];
private readonly destroyRef = inject(DestroyRef);
ngOnInit() {
this.store
.select(selectCurrentProductId)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((productId) => {
this.store.dispatch(
productActions.loadRelatedProducts({ productId })
);
});
this.store
.select(selectRelatedProducts)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((relatedProducts) => {
this.relatedProducts = relatedProducts;
});
}
<!-- related-products.component.html -->
<related-product
*ngFor="let relatedProduct of relatedProducts"
[relatedProduct]="relatedProduct"
></related-product>
The related-products component now knows the entire workflow: observe the
selected product, request related products, observe the result, and clean-up both
subscriptions when component distroyed. This imperative coordination adds code
and hides the connection between the original selection and the data request.
Both subscriptions violate NgRx’s
no-store-subscription
rule.
With event-driven thinking, the product-list component publishes only what
happened: productSelected. The reducer updates the selected product, the effect
loads its related products, and the related-products component declares the data
it needs through a selector. Each part reacts independently, so the component
code becomes shorter, more declarative, and easier to read:
// product-list.component.ts
select(productId) {
this.store.dispatch(
productPageEvents.productSelected({ productId })
);
}
// reducer.ts
on(productPageEvents.productSelected, (state, { productId }) => ({
...state,
currentProductId: productId,
}));
// effects.ts
loadRelatedProducts$ = createEffect(() =>
this.actions$.pipe(
ofType(productPageEvents.productSelected),
// handle loadRelatedproducts
)
);
// related-products.component.ts
readonly relatedProducts$ = this.store.select(selectRelatedProducts);
The template uses the async pipe to unwrap the observable declaratively:
<!-- related-products.component.html -->
<related-product
*ngFor="let relatedProduct of relatedProducts$ | async"
[relatedProduct]="relatedProduct"
></related-product>
Summary
Many common NgRx problems share the same root: treating actions as indirect method calls. This command-driven mindset makes components coordinate Store internals, encourages unrelated publishers to reuse generic actions, and can lead to unnecessary imperative code.
With event-driven thinking, an action describes what happened from the publisher’s perspective. Components publish events and read state through selectors, while reducers and effects decide independently how to react. This keeps components declarative, action histories meaningful, and publishers separate from handlers.
When designing an NgRx action, start by asking “What happened?”, not “What should it do?”