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.

NgRx Store: An Event-Driven Mental Model

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?”