Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RFC: Add events plugin to @ngrx/signals #4580

Open
1 of 2 tasks
markostanimirovic opened this issue Nov 5, 2024 · 35 comments
Open
1 of 2 tasks

RFC: Add events plugin to @ngrx/signals #4580

markostanimirovic opened this issue Nov 5, 2024 · 35 comments

Comments

@markostanimirovic
Copy link
Member

Which @ngrx/* package(s) are relevant/related to the feature request?

signals

Information

This RFC proposes adding the events plugin to the @ngrx/signals package to enable event-based state management with NgRx SignalStore.

Key Principles

  • Combines proven patterns from Flux, NgRx Store, 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.

Prototype

The prototype of the @ngrx/signals/events plugin with a demo application is available at the following link: https://github.com/markostanimirovic/ngrx-signals-events-prototype

Walkthrough

Defining Events

Event creators are defined using the eventGroup function:

// users.events.ts

import { emptyProps, eventGroup, props } from '@ngrx/signals/events';

export const usersPageEvents = eventGroup({
  source: 'Users Page',
  events: {
    opened: emptyProps(),
    refreshed: emptyProps(),
  },
});

export const usersApiEvents = eventGroup({
  source: 'Users API',
  events: {
    usersLoadedSuccess: props<{ users: User[] }>(),
    usersLoadedFailure: props<{ error: string }>(),
  },
});

Performing State Changes

The reducer is added to the SignalStore using the withReducer feature. Case reducers are defined using the when function:

// users.store.ts

import { when, withReducer } from '@ngrx/signals/events';

export const UsersStore = signalStore(
  { providedIn: 'root' },
  withEntities<User>(),
  withRequestStatus(),
  withReducer(
    when(usersPageEvents.opened, usersPageEvents.refreshed, setPending),
    when(usersApiEvents.usersLoadedSuccess, ({ users }) => [
      setAllEntities(users),
      setFulfilled(),
    ]),
    when(usersApiEvents.usersLoadedError, ({ error }) => setError(error)),
  ),
);

Performing Side Effects

Side effects are added to the SignalStore using the withEffects feature:

// users.store.ts

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

export const UsersStore = signalStore(
  /* ... */
  withEffects(
    (
      _,
      events = inject(Events),
      usersService = inject(UsersService),
    ) => ({
      loadUsers$: events
        .on(usersPageEvents.opened, usersPageEvents.refreshed)
        .pipe(
          exhaustMap(() =>
            usersService.getAll().pipe(
              mapResponse({
                next: (users) => usersApiEvents.usersLoadedSuccess({ users }),
                error: (error: { message: string }) =>
                  usersApiEvents.usersLoadedError({ error: error.message }),
              }),
            ),
          ),
        ),
      logError$: events
        .on(usersApiEvents.usersLoadedError)
        .pipe(tap(({ error }) => console.log(error))),
    }),
  ),
);

Dispatched events can be listened to using the Events service.
If an effect returns a new event, it will be dispatched automatically.

Reading State

State and computed signals are accessed via store instance:

// users.component.ts

@Component({
  selector: 'app-users',
  standalone: true,
  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);
}

Dispatching Events

Events are dispatched using the Dispatcher service:

// users.component.ts

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

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

    <button (click)="onRefresh()">Refresh</button>

    <!-- ... -->
  `,
})
export class UsersComponent implements OnInit {
  readonly usersStore = inject(UsersStore);
  readonly dispatcher = inject(Dispatcher);

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

  onRefresh(): void {
    this.dispatcher.dispatch(usersPageEvents.refreshed());
  }
}

It's also possible to define self-dispatching events using the injectDispatch function:

// users.component.ts

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

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

    <button (click)="dispatch.refreshed()">Refresh</button>

    <!-- ... -->
  `,
})
export class UsersComponent implements OnInit {
  readonly usersStore = inject(UsersStore);
  readonly dispatch = injectDispatch(usersPageEvents);

  ngOnInit() {
    this.dispatch.opened();
  }
}

Scaling Up

The reducer can be moved to a separate file using the custom SignalStore feature:

// users.reducer.ts

export function withUsersReducer() {
  return signalStoreFeature(
    { state: type<EntityState<User> & RequestStatusState>() },
    withReducer(
      when(usersPageEvents.opened, usersPageEvents.refreshed, setPending),
      when(usersApiEvents.usersLoadedSuccess, ({ users }) => [
        setAllEntities(users),
        setFulfilled(),
      ]),
      when(usersApiEvents.usersLoadedError, ({ error }) => setError(error)),
    ),
  );
}

The same can be done for effects:

// users.effects.ts

export function withUsersEffects() {
  return signalStoreFeature(
    withEffects(
      (
        _,
        events = inject(Events),
        usersService = inject(UsersService),
      ) => ({
        loadUsers$: events
          .on(usersPageEvents.opened, usersPageEvents.refreshed)
          .pipe(
            exhaustMap(() =>
              usersService.getAll().pipe(
                mapResponse({
                  next: (users) => usersApiEvents.usersLoadedSuccess({ users }),
                  error: (error: { message: string }) =>
                    usersApiEvents.usersLoadedError({ error: error.message }),
                }),
              ),
            ),
          ),
        logError$: events
          .on(usersApiEvents.usersLoadedError)
          .pipe(tap(({ error }) => console.log(error))),
      }),
    ),
  );
}

The final SignalStore implementation will look like this:

// users.store.ts

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

Describe any alternatives/workarounds you're currently using

No response

I would be willing to submit a PR to fix this issue

  • Yes
  • No
@rainerhahnekamp
Copy link
Contributor

Well, I don't see ANY reason why this shouldn't land. 💯👍

@mauriziocescon
Copy link

mauriziocescon commented Nov 5, 2024

Very nice! 👏🏿

I wonder: any specific reason why there isn't a withEventGroup or similar? I guess it would be nice to have everything together (withEventGroup, withReducer, withEffects) whenever it makes sense.

@pawel-twardziak
Copy link
Contributor

Hurray! 🚀 Redux-like state handling is the missing part - at least for us and our project 💯

@e-oz
Copy link
Contributor

e-oz commented Nov 5, 2024

Is it something like global actions? I mean, actions that any store can intercept.

@dmorosinotto
Copy link

dmorosinotto commented Nov 5, 2024

Great work @markostanimirovic as @rainerhahnekamp stated just ship it 😉👍
Only one simple question about naming: with “self-dispatching” events using injectDispath you mean a “strong-typed” dispatcher!?🤔
Because in the example I saw only “manual” callling dispatch.specificEvent(…) don’t understand the self-dispatching meaning, or I miss something?! 🧐

@gabrielguerrero
Copy link
Contributor

@markostanimirovic looks fantastic

@ducin
Copy link
Contributor

ducin commented Nov 5, 2024

Damn it, so I'll be the one "against" 😉

The main selling point behind NGRX-SS for me is its simplicity. The core (withState, withComputed, withMethods) is basically getting native angular stuff - but organizing it and making composable. Simple + composable + thin layer over native APIs -> win.

Nobody enforces to use a "plugin". But if majority of the community walked in redux style direction, I'd see it as a step backwards in terms of simplicity. Redux style includes significant tradeoffs and it does make sense to be used - but not in all scenarios. From my personal experience, I've seen redux far many more times abused than used properly. Hope that doesn't happen to NGRX signal store.

@CrazyJoker
Copy link

Looks great! What about returning multiple events in an effect, similar to using mergeMap or concatMap in @ngrx effects? Is this possible?

@mikezks
Copy link
Contributor

mikezks commented Nov 6, 2024

Great work as always, @markostanimirovic.

This is exactly what we need and turns the Signal Store into a perfect state management solution, where devs may start lean and add the event-based API if needed later.

Very nice! 👏🏿

I wonder: any specific reason why there isn't a withEventGroup or similar? I guess it would be nice to have everything together (withEventGroup, withReducer, withEffects) whenever it makes sense.

Defining Events as with-Feature inside a Signal Store definition would not be a good idea as it leads to coupling of a single Signal Store instance to dispatch Events, which need to have a global nature in Redux-like patterns.

Great work @markostanimirovic as @rainerhahnekamp stated just ship it 😉👍 Only one simple question about naming: with “self-dispatching” events using injectDispath you mean a “strong-typed” dispatcher!?🤔 Because in the example I saw only “manual” callling dispatch.specificEvent(…) don’t understand the self-dispatching meaning, or I miss something?! 🧐

Self-dispatching means, that you do not need to call dispatch to pass in an Event, but just execute a method, pass in a payload if needed and the dispatching works automatically behind the covers.

Is it something like global actions? I mean, actions that any store can intercept.

Yes, exactly that.

@ValentinBossi
Copy link
Contributor

@rainerhahnekamp and @markostanimirovic so so goooood🔥❤️
This was the missing part in signal store:
"Unifies local and global state management with a single approach."

Having back a global event bus is so important🛠️

New people will use anyway signals in angular cause of angular docs and so will choose first the non redux way. so no worries about going in the wrong direction as those with redux needs would stay at ngrx store. this plugin just helps going in the right direction!

@marcindz88
Copy link

marcindz88 commented Nov 6, 2024

I think it's also a nice idea, if someone wants to inject a store that has both dispatch and get-data functionality, then its possible to define:

export const injectUserStore = () => ({ ...inject(UserStore), ...injectDispatch(userEvents) });

Maybe it would be nice to include some store-feature that merges action dispatcher into store or util / docs about it

e.g.

export const injectStoreWithDispatch = <
  T extends Type<StateSource<any>>,
  EventGroup extends Record<string, EventCreator | EventWithPropsCreator>,
>(
  store: T,
  events: EventGroup
): Prettify<InjectDispatchResult<EventGroup> & InstanceType<T>> => ({
  ...inject<InstanceType<T>>(store),
  ...injectDispatch(events),
});

@hudzenko
Copy link

hudzenko commented Nov 6, 2024

Just discovered this feature at ng poland conf. This seems to be a game changer for our team for migration from global store to signal store. Waiting to be landed 🔥

@marcindz88
Copy link

Hi @markostanimirovic I think we should already consider a possibility to connect redux dev-tools.

I have a working hacky solution here, that could be used as an inspiration, but with the ability to change @ngrx internal packages, it would be possible to easily create a fully functional and quality solution.

@timdeschryver
Copy link
Member

I like that events can be created outside of the store 🤩

@ValentinBossi
Copy link
Contributor

ValentinBossi commented Nov 7, 2024

Looks great! What about returning multiple events in an effect, similar to using mergeMap or concatMap in @ngrx effects? Is this possible?

this is (when i understand you @CrazyJoker correctly) - see here https://ngrx.io/guide/eslint-plugin/rules/no-multiple-actions-in-effects - not recommended.

@ValentinBossi
Copy link
Contributor

ValentinBossi commented Nov 7, 2024

could when(event) and on(event) being both when(event)? or both on(event)? in ngrx store it was on() and ofType().

@BaronVonPerko
Copy link

I have mixed feelings about this. This is a great implementation of a redux approach to signal store, but I agree with @ducin, this takes away from the simplicity of what signal-store gives us.

Signals in general is a big step towards simplicity (computed is easier than dealing with rxjs pipes), and signal-store is already a great tool for organizing state with signals.

With the reactivity we get from signals, I guess the question is, do we really need these events?

@ducin
Copy link
Contributor

ducin commented Nov 7, 2024

do we really need these events?

💯. Or, slightly rephrased:

Do we need to put redux-alike impl. everywhere? (given that it is already implemented in so many places)

@e-oz
Copy link
Contributor

e-oz commented Nov 8, 2024

Events are really needed sometimes, but they have a cost. Similar to effect(), they should be avoided, but they can sometimes be useful if used with care.
I recommend adding some guidelines to the documentation and JSDoc.

  • Code that reacts to events should only change internal state, not something global or shared. For example, when an API service clears its cached requests because a global event declared the data is stale, that's a good use of events. When multiple services/components try to change URL params or trigger a redirect because of some event, that's a bad use of events.
  • It becomes more difficult to trace the chain of calls. You cannot “click-through” methods in your IDE to see what causes a side effect. You need smart IDEs that can find event usages, and even then, you only see a list of usages, which might not clearly indicate what you're looking for.
  • Events and listeners are fragile - when you refactor an event, you can't see direct consequences (as mentioned in the previous point). Over time and as the codebase grows, it becomes easier (or safer) to create a new event that “almost” duplicates an existing one. As you can see, this doesn't improve the overall code quality. Because of this, only global events should exist, and direct method calls should be preferred to internal events.
  • Even within one store, if an event triggers mutations in different methods, it can create chaos, weird bugs, and unexpected behavior. Ideally, there should only be one listener per event in a store.

This is a list of reasons why I believe events should be avoided when possible and direct calls should be preferred.
However, there are examples of good event usage, and some cases can benefit noticeably from them.

@LcsGa
Copy link

LcsGa commented Nov 9, 2024

I would definitely need it but I would never dispatch anything from the outside but instead, from within a feature that would trigger an effect on the parent like:
Both a pagination feature with infinite scroll and a category of product in a product store

  • if the category changes the page is reset to the first one + it loads the x first products of that category
  • if the page changes it loads x next products of the current category
  • it loads the x first products (all categories) on init

Would that be a valid use case or am I missing something (pretty new to stores tbh)?

I tried with an effect in the onInit hook but it went into an infinite loop even though the only dependency there was the selectedCategory

EDIT: I could actually use the dispatcher outside of a store/feature in very specific/rare cases but would clearly avoid it as much as possible

@e-oz
Copy link
Contributor

e-oz commented Nov 10, 2024

@LcsGa ,
I would use the URL as the source of truth here. When the category is changed: router.navigate(.., { queryParams: { category, page: 0 }, queryParamsHandling: 'merge' }).

The same applies to pagination.

Then, in the component that displays the items, I would use inputs bound to query params, so they would change on navigation and load items using those params.

But I might have misunderstood your case.

@LcsGa
Copy link

LcsGa commented Nov 10, 2024

Yep, that could be a way of handling this but it doesn't fit my use case:

  • On top of the page I have a select for categories
  • the rest of the same page is a grid with potentially infinite products, loading with an infinite scroll => when I reach the bottom of the page, I load the x next products of the selecte category

@e-oz
Copy link
Contributor

e-oz commented Nov 10, 2024

@LcsGa component with the grid will not be destroyed/reloaded if you navigate to the same route.
Having one source of truth (URL in this case) and letting users save/share their state of browsing using the URL is very valuable.

There are other ways, of course:

  • Convert the store that keeps information about the selected category into a global store (providedIn: root), inject it into the products grid component, and watch some observable/signal;
  • Use events.

@LcsGa
Copy link

LcsGa commented Nov 10, 2024

Yes I completely agree with you but unfortunately I can't do that due to my pagination, as it's made rn. If I had a paginator, I'd do that right away (even though in my case + the router solution, I could simply use an expand) + it's maybe not the best example I gave but my purpose here is to stick with ngrx, to see if what I have in mind would be a good way to use events (I mean, if we didn't have the router option)

@yjaaidi
Copy link

yjaaidi commented Nov 15, 2024

🤯 this would make the SignalStore the most versatile state management ever.

I would emphasize that:

  • it's an opt-in
  • it should improve integration with devtools
  • it enables the "shared event" pattern where one event can be handled by different stores/slices instead of creating interdependencies or manual indirection

what a team! 💪

@davdev82
Copy link

Really awesome and simple implementation. I would definitely like to give it a try. I like the last bit about scaling up where everything is so neat and concise.

@LcsGa
Copy link

LcsGa commented Dec 6, 2024

Hey @markostanimirovic, any news about this package?

@Harpush
Copy link

Harpush commented Dec 14, 2024

@LcsGa component with the grid will not be destroyed/reloaded if you navigate to the same route. Having one source of truth (URL in this case) and letting users save/share their state of browsing using the URL is very valuable.

There are other ways, of course:

  • Convert the store that keeps information about the selected category into a global store (providedIn: root), inject it into the products grid component, and watch some observable/signal;
  • Use events.

I actually got a similar situation. If I have a pagination feature and I want to load from server on page change.

Option 1:
Similar to the new resource - invoke the load in an effect when the signals change. The downside is it can be hard to control when it runs.
Options 2:
The component calls update page. The feature update the page in state and emit an event. The main load rx method listens to the event (probably events) and execute the call.

What's best here?

@LcsGa
Copy link

LcsGa commented Dec 14, 2024

I'd do the second one but until the events package becomes available in the signal-store package, it's not possible.
I had an issue with the first one : I had an effect when one specific value of my state changed but since the effect patches the state, it went in infinite loop (even if I use only one value from my state, it's still the whole state that changes and triggers the effect)

@rosostolato
Copy link
Contributor

Any chance to make it work with not global stores? (with no providedIn root flag)

@mikezks
Copy link
Contributor

mikezks commented Dec 18, 2024

Any chance to make it work with not global stores? (with no providedIn root flag)

In the current PoC Dispatcher is an exported DI Token. So yes, if you provide it in another Injector node local Events can be used.

Nevertheless, I would first analyze if local dispatching is needed at all.

@rosostolato
Copy link
Contributor

Any chance to make it work with not global stores? (with no providedIn root flag)

In the current PoC Dispatcher is an exported DI Token. So yes, if you provide it in another Injector node local Events can be used.

Nevertheless, I would first analyze if local dispatching is needed at all.

That would perfectly fit for stores with multiple instances. My current project would take great advantages of that.

@mikezks
Copy link
Contributor

mikezks commented Dec 18, 2024

That would perfectly fit for stores with multiple instances. My current project would take great advantages of that.

Technically, this would work. Especially if it comes to several instances it is questionable though if it is really helpful to add the Event API here. You either need to create different Dispatcher Tokens or care about different Injector nodes. Both is not that easy to follow depending on the developer teams knowledge about Angular's Dependency Injection concept.

To summerize this, not everything that is technically possible makes sense from an architectural point of view. Most of the time the Event API will make sense for global State Management.

@mikezks
Copy link
Contributor

mikezks commented Dec 18, 2024

@rosostolato, if you like to describe your use case in more detail, this could help to analyze whether additional features would be helpful to finalize the Event API.

@rosostolato
Copy link
Contributor

rosostolato commented Dec 23, 2024

@rosostolato, if you like to describe your use case in more detail, this could help to analyze whether additional features would be helpful to finalize the Event API.

Let me explain using my app as an example. In my app, there are multiple user workspaces, and they work kind of like Chrome tabs. Each workspace is like a separate tab, with its own data and its own store instance. So, when a user is working in a workspace, all the data for that workspace stays in its own store, completely separate from the others.

I know I could just use one global store for each entity to hold all the data and create selectors to filter out the entities for the current workspace. But trust me, that gets messy fast. It’s so much better to have individual stores where each one only holds the data for its specific workspace. This way, data from one workspace won’t interact with or accidentally affect another, and it also makes it much easier to create effects that only interact with the stores within the same workspace.

All I would need to do is provide the dispatcher and the stores in my workspace tab component

@Component({
  selector: 'app-workspace-tab',
  changeDetection: ChangeDetectionStrategy.OnPush,
  providers: [
    Dispatcher
    DataStore,
    WorkspaceStore,
    ... other stores
  ],
  imports: [
  ... rest of the component

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests