Home Blog About
NgRx SignalStore Events: The Power of Events in Your State

NgRx SignalStore Events: The Power of Events in Your State

13 min read angular ngrx signalstore state-management
Table of Contents

The NgRx SignalStore has rapidly become a favorite for managing state in Angular applications due to its simplicity and flexibility. However, as applications grow, we sometimes hit a wall where "methods calling methods" leads to tight coupling and spaghetti code.

In this post, I'll explore the NgRx SignalStore Events plugin, first introduced in NgRx v19.2 and now officially stable as of NgRx 21. This feature brings the power of event-driven architecture to the SignalStore, providing a set of APIs to define and handle events in a reactive and declarative way.

What is the Events Plugin?

The official purpose of the Events plugin is to decouple what happened from how the state changes. It extends the SignalStore with an event-based state management layer, drawing inspiration from the original Flux architecture.

To use the plugin effectively, you need to understand its four main building blocks:

  1. Event: Explicit declarations of an occurrence in your system.
  2. Dispatcher: An event bus that forwards events to their corresponding handlers.
  3. Store: Contains event handlers that manage state transitions and side effects.
  4. View: Reflects state changes and dispatches new events, enabling continuous interaction between the user interface and the underlying system.
graph LR
    Event[Event]:::purple --> Dispatcher[Dispatcher]
    Dispatcher --> Store["Store
(Event Handlers)"] Store --> View[View] View --> Event2[Event]:::purple Event2 --> Dispatcher classDef purple stroke:#a855f7,stroke-width:2px,color:#a855f7;

At its core, it reintroduces familiar concepts from the classic NgRx Store—Events (Actions), Reducers, and Event Handlers (Effects).

Insight: In the SignalStore workshop, we discuss how technically an "Action" in Redux is actually an "Event". This plugin finally corrects that terminology: Events represent something that happened (like a user interaction), rather than a command for something to do.

Instead of your component calling a method that actively updates state and triggers an API call, your component simply dispatches an Event. The store then listens for that event to update its state (via a Reducer) or perform a side effect (via an Effect).

This inversion of control is powerful. It means your UI doesn't need to know what happens when a user clicks a button, only that the button was clicked.

Installation

The events feature is part of the @ngrx/signals package. With NgRx 21, it's now stable and ready for production use.

npm install @ngrx/signals
# or, as I prefer
pnpm add @ngrx/signals

Requirements: Angular 21.x, TypeScript 5.9.x, RxJS ^6.5.x or ^7.5.x

Practical Example

Enough theory—let's see how this works in practice.

I've built a small application to demonstrate these concepts: a TV series searcher. It features a search bar, a results component, and a detailed sidebar that appears when a series is selected. Here's a look at the final result:

Series Search App Demo

All right, let's get down to business. This is the repo if you want to follow all. or check out the live demo here.

Before diving into the code, let's define the scope of our example. We'll focus on two main user interactions:

  1. queryChanged: Triggered when the user searches for a series, either by clicking the search button or pressing Enter.
  2. seriesSelected: Triggered when the user clicks on a series from the results to view more details.

For this UI scope, these are the only two events we need. However, we'll also define API events to handle the success and failure states of our data fetching: one for the search query and another for retrieving the specific details of a selected series.

1. Defining Events

You can declare events individually using the event function. However, using eventGroup is considered a best practice for maintainability. As your feature grows, it's also highly recommended to organize these groups into dedicated files.

We'll define two groups of events: one for UI interactions (SeriesEvents) and one for API responses (SeriesApiEvents).

import { type } from "@ngrx/signals";
import { eventGroup } from "@ngrx/signals/events";
import { Serie, SerieDetail } from "../shared/models";

export const SeriesEvents = eventGroup({
  source: "Series",
  events: {
    // Search Input
    queryChanged: type<{ query: string }>(),
    seriesSelected: type<{ theTvDbId: number }>(),
  },
});

export const SeriesApiEvents = eventGroup({
  source: "Series Api",
  events: {
    // Successful retrieve of data
    loadedSuccess: type<Serie[]>(),
    // Failed retrieve of data
    loadedFailure: type<string>(),
    // Successful retrieve of detail data
    detailLoadedSuccess: type<SerieDetail>(),
    // Failed retrieve of detail data
    detailLoadedFailure: type<string>(),
  },
});

💡 Naming Tip: Treat events as historical facts. Use past tense (selected, loaded, changed) and focus on user intent (queryChanged) rather than the physical interaction (buttonClicked). This makes your Store resilient to UI changes.

2. Building the Store

First, we define our state shape and initialize the store. At this point, it's just a standard SignalStore with state.

export interface SeriesState {
  series: Serie[];
  searchState: "initial" | "loading" | "loaded" | "error";
  selectedId: number | null;
  // ... other state properties
}

const initialSeriesState: SeriesState = {
  series: [],
  searchState: "initial",
  selectedId: null,
};

export const SeriesStore = signalStore(
  withState<SeriesState>(initialSeriesState),
  // ... we will add features here
);

💡 State Tip: Keep your state minimal. Don't store derived data like filteredSeries or activeSeries. Instead, store the raw series array and use withComputed to derive specific views. This prevents state synchronization bugs and leverages the power of Signals memoization.

Note: Up to this point, the code is identical to a standard SignalStore. The divergence begins when we start handling side effects with events instead of methods.

3. Reactive State Updates (Reducers)

The withReducer feature serves as the centralized place for state transitions. Here, we subscribe to various events—whether triggered by user interactions (like starting a search) or external systems (like an API response)—and strictly define how the state should evolve in response.

This keeps our state logic pure:

// Inside signalStore...
withReducer(
  // Note the use of destructured arguments to access the `payload` directly in the callback function.
  // --- Search Flow ---
  // User changes query -> Set loading state & update query immediately
  on(SeriesEvents.queryChanged, ({ payload: { query } }) => ({
    query,
    searchState: "loading",
  })),
  // API returns success -> Update series & set loaded state
  on(SeriesApiEvents.loadedSuccess, ({ payload: series }) => ({
    series,
    searchState: "loaded",
  })),
  // API fails -> Set error state
  on(SeriesApiEvents.loadedFailure, () => ({
    searchState: "error",
  })),

  // --- Details Flow ---
  // User selects a series -> Update selected ID
  on(SeriesEvents.seriesSelected, ({ payload: { theTvDbId } }) => ({
    selectedId: theTvDbId,
  })),
  // API returns details -> Store details
  on(SeriesApiEvents.detailLoadedSuccess, ({ payload: seriesDetail }) => ({
    seriesDetail,
  }))
),

Technical Note: The function passed to on(...) receives two things: the event (with its payload) and the current state. Its only job is to return the new state (or the part that changed).

4. Handling Side Effects (Event Handlers)

Next, we listen for user interactions to trigger side effects (like API calls). We use withEventHandlers to catch the events.

Note: In NgRx v21, withEffects was renamed to withEventHandlers. Ensure you are using NgRx v21 or later.

Notice something important: We do not update the state here. We simply trigger the side effect and dispatch the result as a new event.

// Inside signalStore...
withEventHandlers((store, events = inject(Events), seriesService = inject(SeriesService)) => ({
  loadSeriesByQuery$: events
    .on(SeriesEvents.queryChanged)
    .pipe(
      // 1. Debounce to avoid spamming the API
      debounceTime(300),
      // 2. Call the API
      switchMap(({ payload }) =>
        seriesService.searchSeries(payload.query).pipe(
          // 3. Map result to Success/Failure events
          mapResponse({
            next: (series) => SeriesApiEvents.loadedSuccess(series),
            error: (e: Error) => SeriesApiEvents.loadedFailure(e.message),
          }),
        ),
      ),
    ),
  loadSeriesDetail$: events
    .on(SeriesEvents.seriesSelected)
    .pipe(
      filter(({ payload }) => !!payload.theTvDbId),
      switchMap(({ payload }) =>
        seriesService.getSeriesDetail(payload.theTvDbId).pipe(
          mapResponse({
            next: (detail) => SeriesApiEvents.detailLoadedSuccess(detail),
            error: (e: Error) => SeriesApiEvents.detailLoadedFailure(e.message),
          }),
        ),
      ),
    ),
})),

5. Dispatching from Components

The SearchContainerComponent connects our dumb components to the store and dispatches events.

@Component({
  // ... imports and providers
  template: `
    <app-search [state]="store.searchState()" (searchQuery)="searchSeries($event)" />
    <app-results [series]="store.series()" [state]="store.searchState()" (selected)="onSeriesSelected($event)" />
    <nz-drawer [nzVisible]="isDrawerVisible()" ...>
      <app-series-detail ... />
    </nz-drawer>
  `,
})
export class SearchContainerComponent {
  readonly store = inject(SeriesStore);
  private readonly dispatch = injectDispatch(SeriesEvents);

  isDrawerVisible = computed(() => !!this.store.selectedId());

  searchSeries(formValue: string) {
    this.dispatch.queryChanged({ query: formValue });
  }

  onSeriesSelected(serie: Serie) {
    this.dispatch.seriesSelected({ theTvDbId: serie.externals.thetvdb });
  }
}

Notice how simple the component becomes:

  • We use injectDispatch to get a dispatcher for our specific SeriesEvents.
  • We act as a bridge: Read from the store (signals), Write via events.
  • We don't call methods like store.loadSeries(). We simply announce: "The query changed".

This approach gives us a clear Event Inventory of our application. We can group and organize these events in a way that makes sense for the business domain, rather than being tied to implementation details.

Scoped Events (New in NgRx 21)

By default, the Dispatcher and Events services operate in a global scope where all dispatched events are handled application-wide. However, NgRx v21 introduces Scoped Events, which are crucial for isolation in scenarios like Micro-Frontends or specific feature subtrees.

You can configure the scope when injecting the Events service or Dispatcher:

  • self (default): An event dispatched and handled only within the local scope.
  • parent: An event is forwarded to the parent dispatcher.
  • global: An event is forwarded to the global dispatcher.

From the docs: "In some cases, event handling should be isolated to a particular feature or component subtree. Typical examples include local state management scenarios where events should stay within a specific feature, or micro-frontend architectures where each remote module needs its own isolated event scope."

// Example of scoped injection
const dispatch = injectDispatch(SeriesEvents, { scope: "self" });

Why Use SignalStore Events?

You might be asking, "Why add this extra boilerplate?"

It's a valid question. For simple features, the standard method-based SignalStore is my go-to. However, the Event plugin shines when:

  • Complex Chains: One action triggers multiple independent updates across different slices of state.
  • Decoupling: You want your "Smart Components" to be even dumber. They just announce "User clicked Save" without knowing what "Save" entails.
  • Orchestration: You need to coordinate flows between multiple stores.
  • Maintainability & Clarity: As features grow, it's easier to trace logic by following event names than by jumping through multiple method calls. This structure is designed for when things get complicated—it provides clarity for your future self (6 months from now). There's a reason the Redux pattern is a staple in enterprise applications: the imposed order pays off at scale.

Conclusion

The NgRx SignalStore Events plugin is a fantastic addition for developers who miss the structure of the Redux pattern but love the specialized nature of SignalStore. With its promotion to stable status in NgRx 21 and powerful new features like Scoped Events, it's now ready for production use in complex applications.

It provides a clean, reactive way to handle complex state flows without sacrificing the developer experience we've come to enjoy with Signals. By strictly separating events (intent) from state transitions (reducers) and side effects (handlers), we gain a level of clarity and maintainability that pays off as our application grows.

References

Logo
Arcadio QuinteroSystems Engineer

Sharing practical knowledge on software architecture, Angular best practices, and the evolving landscape of web development.

© 2026 Arcadio Quintero. All rights reserved.

Built withAnalog&Angular