The new Event API in NgRx Signal Store

NgRX SignalStore has released an experimental feature called NgRx SignalStore Events, marking a significant extension to the @ngrx/signals ecosystem. This lightweight state management solution, powered by signals, has already simplified state updates. Now, it brings the familiar flux-inspired redux pattern to the ecosystem with reduced boilerplate

As highlighted in Marko Stanimirović's blog post https://dev.to/ngrx/announcing-events-plugin-for-ngrx-signalstore-a-modern-take-on-flux-architecture-4dhn, this experimental release deserves attention and practical exploration.

Understanding Benefits of Events

Events are a powerful mechanism that can transform your Angular application into a leaner and cleaner system. What benefits can we gain?

  • Decoupling: Producers of events simply announce "something happened," and consumers can react to it.
  • Flexibility: EDA allows us to add new features or modify existing ones by simply introducing new events.
  • Separation of concerns: With the usage of effects, we clearly separate side effects from core logic, which helps make our codebase easier to understand, debug, and maintain.

We could already implement some “kind” CQRS (Command Query Responsibility Segregation) with SignalStore in our workshops. Our methods act as commands without return values (though "without" might be more precise than "any" here 😉). When we send a command, it triggers our marble run—behind the scenes, we query the database and transform data. Eventually, we receive the results as an updated state that we track in our frontend, though sometimes we might not receive any update at all.

This picture illustrates the concept, simplified for a component and a store.

CleanShot 2025-05-21 at 09.58.41.png

So why are we excited? First, we can now work with events that represent either commands or events in our system. For example, an event named "loadSettings" acts as a command—we expect someone to pick it up and load these settings. Another example is "loadSettingsFailed," which is an event indicating something happened within the system.

Furthermore, we can send messages between stores and capture events within any injection context:

 readonly #events = inject(Events);
 constructor() {
      this.#events
        .on(/* The event we want */)
        .pipe(takeUntilDestroyed())
        .subscribe(() => /* Process */ );
 }

Currently, events are not buffered or replayed—the current implementation uses an RxJS Subject, so when you lose an event due to timing issues, it's gone.

With these possibilities, I can envision potential migration scenarios from @ngrx/store to the new signal store.

A possible approach would be to use the signal store with the flux/redux pattern while leaving existing stores untouched, since there's no need to refactor well-documented and tested code solely for new technology. When you need to enhance an existing store, you can evaluate whether upgrading it to the signal store makes sense. To enable interaction between the stores, we can dispatch a global action and translate between the event systems.

But enough with theory let’s look into an example.

Practical Implementation: Settings Store

To demonstrate the practical value, let's examine a settings store implementation that manages application-wide settings. First we define our application state we want to manage:

export interface SettingsState {
  theme: "light" | "dark" | "system";
  notifications: boolean;
  language: string;
  lastUpdated: string | null;
  errorMessage: string | null;
}

export const initialState: SettingsState = {
  theme: "light",
  notifications: false,
  language: "en",
  lastUpdated: null,
  errorMessage: null,
};

Note: In a hexagonal architecture, we would separate our core settings model from our state. However, to keep this example as simple as possible, we're using a lean approach.

Event Groups: Organising Application Logic

Let's discuss the Dispatcher service, which references both EventsService and ReducerEventsService (these derive from BaseEvents and share identical implementations). When called, the dispatch function broadcasts events simultaneously through both channels. The ReducerEvents service works specifically with the withReducer feature.

Now, let's define our events.

The implementation features two strategic event groups:

  1. UI Events: Handle user interactions through settingsEvents
  2. API Events: Manage backend communication through settingsApiEvents
export const settingsEvents = eventGroup({
  source: "Settings",
  events: {
    themeChanged: type<{ theme: "light" | "dark" | "system" }>(),
    notificationsChanged: type<{ notificationsEnabled: boolean }>(),
    languageChanged: type<{ language: string }>(),
    loadSettings: type<void>(),
    saveSettings: type<void>(),
  },
});

export const settingsApiEvents = eventGroup({
  source: "SettingsApi",
  events: {
    settingsLoadFailed: type<{ error: string }>(),
    settingsSaved: type<{ settings: Omit<SettingsState, "errorMessage"> }>(),
    settingsLoaded: type<{ settings: Omit<SettingsState, "errorMessage"> }>(),
  },
});

The API is clear and concise. I have defined two event groups: The first one, "settingsEvents," combines UI events that occur when users interact with the interface—like changing the theme, clicking save, or triggering a load event when entering the dialog.

The second event group defines API events. For example, when a loadSettings event is captured by a side effect, it may request settings from the backend. This could be a long-running task that might succeed or fail. When we receive a response, we create an API event to notify our state management that the data is ready for processing.

Technical Deep Dive

The SettingsStore implementation showcases the power of custom signalStoreFeatures, ensuring clean code organization and future extensibility.

export const SettingsStore = signalStore(
  withState<SettingsState>(initialState),
  withSettingsReducer(),
  withThemeFeature(),
  withSettingsPersistenceFeature(),
  withHooks({
    onInit: () => {
      const dispatch = injectDispatch(settingsEvents);
      dispatch.loadSettings();
    },
  })
);

We utilize custom signalStoreFeatures to improve separation of concerns and demonstrate how we can extend our store with events in the future without creating a tangled mess of spaghetti code.

The withSettingsReducer feature, as its name suggests, is responsible for combining the previous state with an event to produce a new state.

export function withSettingsReducer() {
  return signalStoreFeature(
    withReducer(
      on(settingsEvents.themeChanged, ({ payload }) => ({
        theme: payload.theme,
      })),
      on(settingsEvents.notificationsChanged, ({ payload }) => ({
        notifications: payload.notificationsEnabled,
      })),
      on(settingsEvents.languageChanged, ({ payload }) => ({
        language: payload.language,
      })),
      on(settingsApiEvents.settingsSaved, () => ({
        lastUpdated: new Date().toISOString(),
      })),
      on(settingsApiEvents.settingsLoadFailed, ({ payload }) => ({
        errorMessage: payload.error,
      })),
      on(settingsApiEvents.settingsLoaded, ({ payload }) => payload.settings)
    )
  );
}

The withReducer and on functions from the @ngrx/signals/events namespace enable listening to events and updating state with partial state updaters. As you can see from the signal store's design, we've optimized state immutability without needing to create shallow copies through object spread operators.

Now lets look into the withThemeFeature which should give you an example how to use an effect to update for example the dom to set the current theme.

export function withThemeFeature() {
  return signalStoreFeature(
    withEffects(
      (store, events = inject(Events)) => ({
        loadTheme$: events.on(
          settingsApiEvents.settingsSaved,
          settingsApiEvents.settingsLoaded).pipe(
          tap(({ payload }) => document.documentElement.setAttribute(
                                                          'data-theme', 
                                                                    payload.settings.theme))
        ),
      })
    )
  )
}

Here we listing to both events when the settings are loaded or when they are saved to update the dom.

Finally let’s look into the component usage:

export class SettingsComponent {
  protected readonly store = inject(SettingsStore);
  protected readonly dispatch = injectDispatch(settingsEvents);

  onThemeChange(theme: 'light' | 'dark' | 'system') {
    this.dispatch.themeChanged({ theme });
  }

  onNotificationsChange(notifications: boolean) {
    this.dispatch.notificationsChanged({ notificationsEnabled: notifications });
  }

  onLanguageChange(language: string) {
    this.dispatch.languageChanged({ language });
  }

  onSave() {
    this.dispatch.saveSettings();
  }
}

As Marko described in his article, injectDispatch provides us with an elegant API. Instead of using a generic dispatch function to construct events, we get a fluent API that's automatically generated from our event settings, as demonstrated above.

The whole project is here https://stackblitz.com/~/github.com/wolfmanfx/signal-events

MetaReducer

One feature I had missed was the meta reducer. With such a meta reducer, I can implement global undo/redo functionality or connect to the dev tools using our ngrx-toolkit.

export type StoreType<T> = {
    [K in keyof T]: Signal<T[K]>;
}

export function withMetaReducer<T>(metaReducer: (ev: EventInstance<string, unknown>, store: T) => void) {
  return signalStoreFeature(
    withMethods((store) => ({
      metaReducer: rxMethod<EventInstance<string, unknown>>((c$) =>
        c$.pipe(
          tap((ev) => {
            metaReducer(ev, store as T);
          })
        )
      ),
    })),
    withHooks({
      onInit: ({ metaReducer }) => {
        metaReducer(inject(Events).on());
      },
    })
  );
}

The usage is straightforward—in this example, I am simply logging the events to the console.

  withMetaReducer<StoreType<SettingsState>>((*ev*, *store*) => {    
      console.log(`MetaReducer: ${*ev*.type}`);    
      console.log(*store*.theme());  
    })

Looking Forward

Here is my biased opinion regarding the Redux pattern itself. During code reviews, I've found that many companies struggle to deliver clean applications using @ngrx/store. Developers often have difficulty understanding the code workflow because a single event can trigger multiple events (fan-out), causing applications to grow organically without clear direction. However, some companies have delivered excellent applications using Redux. So if you're one of these successful companies and want to shift to more lightweight stores while maintaining your current pattern and interactions, the NgRx team has provided a path forward with events.

Nevertheless, this is excellent work and a great project. I'm looking forward to the next update.

More on this: Workshops and Reviews

eBook: Modern Angular

Bleibe am Puls der Zeit und lerne, moderne und leichtgewichtige Lösungen mit den neuesten Angular-Features zu entwickeln: Standalone, Signals, Build-in Dataflow.

Gratis downloaden