DEV Community

Cover image for Announcing Events Plugin for NgRx SignalStore: A Modern Take on Flux Architecture
Marko Stanimirović for NgRx

Posted on

Announcing Events Plugin for NgRx SignalStore: A Modern Take on Flux Architecture

We are pleased to announce the introduction of the Events plugin for NgRx SignalStore, released as part of NgRx v19.2! This new addition brings the power of event-based state management to SignalStore, enabling developers to create scalable applications more effectively.


Experimental Stage 🚧

The Events plugin is currently marked as experimental. This means its APIs are subject to change, and modifications may occur in future versions without standard breaking change announcements until it is deemed stable. We encourage you to try it out, provide feedback, and help shape its future!


Flux Architecture 🔄

This plugin takes inspiration from the original Flux architecture and incorporates the best practices and patterns from NgRx Store, NgRx Effects, and RxJS.

Application Architecture with Events Plugin

The application architecture with the Events plugin is composed of the following building blocks:

  1. Event: Describes an occurrence within the system. Events are dispatched to trigger state changes and/or side effects.
  2. Dispatcher: An event bus that forwards events to their corresponding handlers in the stores.
  3. Store: Contains reducers and effects that manage state and handle side effects, maintaining a clean and predictable application flow.
  4. View: Reflects state changes and dispatches new events, enabling continuous interaction between the user interface and the underlying system.

By dispatching events and reacting to them, the what (the event that occurred) is decoupled from the how (the state changes or side effects that result), leading to predictable data flow and more maintainable code.


Walkthrough 🛠️

The Events plugin provides a set of APIs for defining and handling events in a reactive and declarative manner. The following example demonstrates how these APIs can be used to implement state and behavior orchestration through events.

Defining Events

Events are created using the eventGroup function, which organizes related events under a common source. This function accepts an object with two properties:

  • source: identifies the origin of the event group (e.g., 'Users Page', 'Users API').
  • events: a dictionary of named event creators, where each key defines the event name and each value defines the payload type using the type helper from the core @ngrx/signals package.
import { type } from '@ngrx/signals';
import { eventGroup } from '@ngrx/signals/events';

export const usersPageEvents = eventGroup({
  source: 'Users Page',
  events: {
    opened: type<void>(),
    refreshed: type<void>(),
  },
});

export const usersApiEvents = eventGroup({
  source: 'Users API',
  events: {
    loadedSuccess: type<User[]>(),
    loadedFailure: type<string>(),
  },
});
Enter fullscreen mode Exit fullscreen mode

Performing State Changes

To handle state changes in response to events, SignalStore provides the withReducer feature. Case reducers are defined using the on function, which maps one or more events to a case reducer function. Each case reducer receives the event and returns the state update. It supports three forms of return values: a partial state object, a partial state updater, and an array of partial state objects and/or updaters.

import { signalStore } from '@ngrx/signals';
import { setAllEntities, withEntities } from '@ngrx/signals/entities';
import { on, withReducer } from '@ngrx/signals/events';

export const UsersStore = signalStore(
  { providedIn: 'root' },
  withEntities<User>(),
  withRequestStatus(),
  withReducer(
    on(usersPageEvents.opened, usersPageEvents.refreshed, setPending),
    on(usersApiEvents.loadedSuccess, ({ payload }) => [
      setAllEntities(payload),
      setFulfilled(),
    ]),
    on(usersApiEvents.loadedFailure, ({ payload }) => setError(payload)),
  ),
);
Enter fullscreen mode Exit fullscreen mode

💡 Implementation of the withRequestStatus feature and its state updaters is available in the official SignalStore documentation.


Performing Side Effects

Side effects are handled using the withEffects feature. This feature accepts a function that receives the store instance as an argument and returns a dictionary of effects. Each effect is defined as an observable that reacts to specific events using the Events service. If an effect emits a new event, that event will be automatically dispatched.

import { Events, withEffects } from '@ngrx/signals/events';
import { mapResponse } from '@ngrx/operators';

export const UsersStore = signalStore(
  /* ... */
  withEffects(
    (
      store,
      events = inject(Events),
      usersService = inject(UsersService),
    ) => ({
      loadUsers$: events
        .on(usersPageEvents.opened, usersPageEvents.refreshed)
        .pipe(
          exhaustMap(() =>
            usersService.getAll().pipe(
              mapResponse({
                next: (users) => usersApiEvents.loadedSuccess(users),
                error: (error: { message: string }) =>
                  usersApiEvents.loadedFailure(error.message),
              }),
            ),
          ),
        ),
      logError$: events
        .on(usersApiEvents.loadedFailure)
        .pipe(tap(({ payload }) => console.log(payload))),
    }),
  ),
);
Enter fullscreen mode Exit fullscreen mode

Reading State

The plugin doesn’t change how the state is exposed or consumed. Therefore, components can access state and computed signals by using the store instance.

@Component({
  selector: 'app-users',
  template: `
    <h1>Users</h1>

    @if (usersStore.isPending()) {
      <p>Loading...</p>
    }

    <ul>
      @for (user of usersStore.entities(); track user.id) {
        <li>{{ user.name }}</li>
      }
    </ul>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UsersComponent {
  readonly usersStore = inject(UsersStore);
}
Enter fullscreen mode Exit fullscreen mode

Dispatching Events

To initiate state changes or side effects, events can be dispatched using the Dispatcher service. It provides a dispatch method that takes an event as input.

import { Dispatcher } from '@ngrx/signals/events';

@Component({
  /* ... */
  template: `
    <h1>Users</h1>

    <button (click)="refresh()">Refresh</button>
  `,
})
export class UsersComponent {
  readonly usersStore = inject(UsersStore);
  readonly dispatcher = inject(Dispatcher);

  constructor() {
    this.dispatcher.dispatch(usersPageEvents.opened());
  }

  refresh(): void {
    this.dispatcher.dispatch(usersPageEvents.refreshed());
  }
}
Enter fullscreen mode Exit fullscreen mode

For convenience, self-dispatching events can be generated using the injectDispatch function, which returns an object with methods matching the provided event creators.

import { injectDispatch } from '@ngrx/signals/events';

@Component({
  /* ... */
  template: `
    <h1>Users</h1>

    <button (click)="dispatch.refreshed()">Refresh</button>
  `,
})
export class UsersComponent {
  readonly usersStore = inject(UsersStore);
  readonly dispatch = injectDispatch(usersPageEvents);

  constructor() {
    this.dispatch.opened();
  }
}
Enter fullscreen mode Exit fullscreen mode

Scaling Up

As applications grow in complexity, reducers and effects can be extracted into standalone features to improve maintainability.

Extracted Reducer

export function withUsersReducer() {
  return signalStoreFeature(
    { state: type<EntityState<User> & RequestStatusState>() },
    withReducer(
      on(usersPageEvents.opened, usersPageEvents.refreshed, setPending),
      on(usersApiEvents.loadedSuccess, ({ payload }) => [
        setAllEntities(payload),
        setFulfilled(),
      ]),
      on(usersApiEvents.loadedFailure, ({ payload }) => setError(payload)),
    ),
  );
}
Enter fullscreen mode Exit fullscreen mode

Extracted Effects

export function withUsersEffects() {
  return signalStoreFeature(
    withEffects(
      (
        store,
        events = inject(Events),
        usersService = inject(UsersService),
      ) => ({
        loadUsers$: events
          .on(usersPageEvents.opened, usersPageEvents.refreshed)
          .pipe(
            exhaustMap(() =>
              usersService.getAll().pipe(
                mapResponse({
                  next: (users) => usersApiEvents.loadedSuccess(users),
                  error: (error: { message: string }) =>
                    usersApiEvents.loadedFailure(error.message),
                }),
              ),
            ),
          ),
        logError$: events
          .on(usersApiEvents.loadedFailure)
          .pipe(tap(({ payload }) => console.log(payload))),
      }),
    ),
  );
}
Enter fullscreen mode Exit fullscreen mode

Final Store Composition

export const UsersStore = signalStore(
  { providedIn: 'root' },
  withEntities<User>(),
  withRequestStatus(),
  withUsersReducer(),
  withUsersEffects(),
);
Enter fullscreen mode Exit fullscreen mode

Key Takeaways 🔑

The Events plugin:

  • Combines proven patterns from Flux, NgRx Store, NgRx Effects, and RxJS.
  • Seamlessly integrates with existing SignalStore features.
  • Extends Flux architecture with powerful customization options.
  • Unifies local and global state management with a single approach.

Upcoming NgRx Workshops 🎓

With NgRx usage continuing to grow with Angular, many developers and teams still need guidance on how to architect and build enterprise-grade Angular applications. We are excited to introduce upcoming workshops provided directly by the NgRx team!

We're offering one to three full-day workshops that cover the basics of NgRx to the most advanced topics. Whether your teams are just starting with NgRx or have been using it for a while - they are guaranteed to learn new concepts during these workshops.

Visit our workshops page for more details. The next workshops are scheduled for:


Thanks to All Our Contributors and Sponsors! 🏆

NgRx continues to be a community-driven project. Design, development, documentation, and testing - all are done with the help of the community. Visit our community contributors section to see every person who has contributed to the framework.

If you are interested in contributing, visit our GitHub page and look through our open issues, some marked specifically for new contributors. We also have active GitHub discussions for new features and enhancements.

We want to give a big thanks to our Gold sponsor, Nx! Nx has been a longtime promoter of NgRx as a tool for building Angular applications and is committed to supporting open-source projects that they rely on.

We want to thank our Bronze sponsor, House of Angular!

Lastly, we also want to thank our individual sponsors who have donated once or monthly.


Sponsor NgRx 🤝

If you are interested in sponsoring the continued development of NgRx, please visit our GitHub Sponsors page for different sponsorship options, or contact us directly to discuss other sponsorship opportunities.

Follow us on Twitter and LinkedIn for the latest updates about the NgRx platform.

Top comments (9)

Collapse
 
frankitch profile image
Frankitch • Edited

Yeah, what a great feature!! Thanks!

For a future NGRX article, would love to see how Signal Store could combine with the new angular httpResource 😉

Collapse
 
antoniosantana profile image
AntonioSantanaCorp

Awesome!!! I love this plugin!

Collapse
 
draylegend profile image
Vladimir Drayling

by using on we don't have the access to the prev state. Now only the payload is available

Collapse
 
markostanimirovic profile image
Marko Stanimirović

It's possible of course. Check the "Performing State Changes" section:

Each case reducer receives the event and returns the state update. It supports three forms of return values: a partial state object, a partial state updater, and an array of partial state objects and/or updaters.

Example:

const incrementBy = event('incrementBy', type<number>());

const CounterStore = signalStore(
  withState({ count: 0 }),
  withReducer(
    on(
      incrementBy,
      ({ payload }) => (state) => ({ count: state.count + payload })
    ),
  )
Enter fullscreen mode Exit fullscreen mode
Collapse
 
thedevtoni profile image
Anthony Diaz • Edited

Any chance a couple of example with updateEntity or setEntity can be added to the article?

I'm fighting with this but keep getting type errors.

 on(ExperimentRunEvents.toggleRunSelection, ({ payload }) => (state) => {
      const run = state.entityMap[payload.sheetManifestId];

      if (!run) {
        return state;
      }

      return setEntity(
        {
          ...run,
          selected: !run.selected,
        },
        { selectId },
      );
    })

Enter fullscreen mode Exit fullscreen mode

Image description

Thread Thread
 
markostanimirovic profile image
Marko Stanimirović

The state updater needs to be returned directly to the case reducer handler as a result. In your example, the state updater is returned from another state updater. To fix it, you should execute the updater returned by the setEntity function:

return setEntity(
  /* ... */
)(state); // 👈
Enter fullscreen mode Exit fullscreen mode

This can be simplified if the case reducer handler has the current state as the second argument:

on(ExperimentRunEvents.toggleRunSelection, ({ payload }, state) => {
  // ...

  return setEntity(/* ... */);
})
Enter fullscreen mode Exit fullscreen mode

If you want, feel free to open a feature request for this in the NgRx repo: github.com/ngrx/platform/issues

Collapse
 
draylegend profile image
Vladimir Drayling

I'm such a noob XD thank you <3

Collapse
 
tom_white_ec9c64700e677a9 profile image
Tom White

In large apps that are slowly migrating from NgRx Store to NgRx Signal Store, is there a method to get a Signal Store Effect to listen to NgRx Store Actions?
I know it's early days, but if the team can't provide this, many people will create a lot of ugly code.

Collapse
 
draylegend profile image
Vladimir Drayling

Triggering effects:
provideEnvironmentInitializer(() => inject(RouterStore))