platform icon indicating copy to clipboard operation
platform copied to clipboard

`@ngrx/signals/events`: Provide Support for Scoped Events

Open markostanimirovic opened this issue 8 months ago β€’ 4 comments

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

signals

Information

Currently, all events are dispatched using the globally provided Dispatcher. However, there are at least two scenarios where having scoped dispatchers and event handling would be beneficial:

  • Local state management: When multiple instances of the same component are initialized, each with its own store instance, events dispatched from a specific component instance should be handled exclusively by the store instance associated with that same component.
  • Microfrontends: Each microfrontend may require its own isolated dispatcher.

Solution

This functionality can be introduced by supporting a tree of dispatchers. Components or microfrontends that require a scoped dispatcher can use the provideDispatcher function. In the following example, events dispatched from MyComponent can only be handled by stores provided at the same level (MyComponent) or in its descendants.

import { event, provideDispatcher } from '@ngrx/signals/events';

const myEvent = event('myEvent');

@Component({
  providers: [
    provideDispatcher(),
    MyStore1,
    MyStore2,
  ],
})
export class MyComponent {
  readonly dispatcher = inject(Dispatcher);

  myMethod(): void {
    this.dispatcher.dispatch(myEvent());
  }
}

The dispatcher tree follows the same hierarchy as Angular's injector tree: events are visible to the level at which they are dispatched and to all descendants. For example, events dispatched to the root dispatcher are available to all nested dispatchers, but events dispatched to a child dispatcher are not visible to ancestors.

If needed, events can be explicitly dispatched to a parent or root dispatcher:

  myMethod(): void {
    this.dispatcher.dispatch(myEvent(), { parent: true });
    // or
    this.dispatcher.dispatch(myEvent(), { root: true });
  }

The proposed approach is lightweight, highly flexible, and aligns naturally with Angular’s design principles.

Describe any alternatives/workarounds you're currently using

While it is possible to include a unique identifier in the event payload and manually filter events within reducers and effects, this approach adds unnecessary overhead and negatively impacts the developer experience.

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

  • [x] Yes
  • [ ] No

markostanimirovic avatar May 10 '25 00:05 markostanimirovic

LGTM, thx @markostanimirovic.

For even more fine-grained dispatching support (e. g. different dispatcher instances in one template) one can use a custom Directive with provideDispatcher() in its providers: [].

mikezks avatar May 10 '25 08:05 mikezks

Can look like this:

@Directive({
  selector: '[provideDispatcher]',
  providers: [
    provideDispatcher()
  ]
})
export class ProvideDispatcherDirective {}

This can be useful in edge cases where the store instance itself is provided inside another Directive as well.

mikezks avatar May 10 '25 08:05 mikezks

That would perfectly fit my needs, thanks! 😁

LcsGa avatar May 11 '25 06:05 LcsGa

Hi there, can it be possible to have access to the store in withReducers?

So I can do something like, using the passed in props, (from the main store), rather than injecting constants in each reducer function?

  return signalStoreFeature(
    {
      state: type<UserProfileState>(),
      props: type<{
        logger: LoggingService,
        storeName: string,
      }>(),
    },
    withReducer(
      on(
        authEvents.logoutSuccess,
        () => (state) => {

          const logger = inject(LoggingService) --> if had access to store, would not be needed!
          const storeName = 'UserProfileStore'; --> if had access to store, would not be needed!
          
          logger.info('Clearing user profile state on logoutSuccess.', storeName);
          return{
            ...state,
            userProfileBasic: null,
            userProfileFull: null,
            status: UserProfileOperationStatusEnum.IDLE,
            error: null
          }
        }
      )
    )
  )
}

dreamstar-enterprises avatar Jun 06 '25 18:06 dreamstar-enterprises

I don't see anything that would impact negatively the core functionality and that's exactly what I need, thank you!

rosostolato avatar Jul 16 '25 15:07 rosostolato

Just want to present an alternative idea (disclaimer: which is highly opinionated)

I would propose to not go with a provideDispatcher() approach. Let me explain why:

As it generally of course fits with Angular's DI System it has imho one major drawback: It can be overlooked quite easily.

I would suggest to introduce simply the possibility to provide a scope in the dispatch-function:

import { event, provideDispatcher } from '@ngrx/signals/events';

const myEvent = event('myEvent');

@Component({
  providers: [
    MyStore1,
    MyStore2,
  ],
})
export class MyComponent {
  readonly dispatcher = inject(Dispatcher);

  myMethod(): void {
     this.dispatcher.dispatch(myEvent(), { scope: 'Cart' });
  }
}

michaelbe812 avatar Aug 06 '25 06:08 michaelbe812

@michaelbe812 How would scope be handled in the store? Can you provide additional code snippets of potential usage?

With provideDispatcher there is no additional effort - withReducer and withEffects are listening to events the same way as before.

markostanimirovic avatar Aug 06 '25 08:08 markostanimirovic

@michaelbe812 How would scope be handled in the store? Can you provide additional code snippets of potential usage?

With provideDispatcher there is no additional effort - withReducer and withEffects are listening to events the same way as before.

probably it might look something like this:


export const Store = signalStore(
  withReducer(
    on({event: myEvent, scope: 'Cart'}, (event, state) => ({// ... do something})),
  ),
   withEffects(
      (store, events = inject(Events), cartService = inject(CartService)) => ({
        doSomething$: events
          .on({event: booksApiEvents.loadedFailure, scope: 'Cart'})
          .pipe(tap(({ payload }) => console.error(payload))),
      }),
    ),
  );

Alternatively I could imagine also

export const Store = signalStore(
  withReducer(
    on(myEvent, (event, state) => ({// ... do something}))
  ,{scope: 'Cart'}),
   withEffects(
      ( store, events = inject(Events), cartService = inject(CartService)) => ({
        doSomething$: events
          .on(booksApiEvents.loadedFailure)
          .pipe(tap(({ payload }) => console.error(payload))),
      }),
    , {scope: 'Cart'}),
  );

michaelbe812 avatar Aug 07 '25 06:08 michaelbe812

@michaelbe812 Regardless of additional adjustments of existing APIs that would be required by introducing scope, the limitation of this approach that provideDispatcher solves is handling local events in multiple instances of the same store separately.

Example:

@Component({
  // ...
  template: `
    @for(product of products(); track: product.id) {
      <product-card [product]="product" />
    }
  `,
})
export class ProductList { /* ... */ }

export const ProductCardStore = signalStore(
  // ...
  withReducer(
    on(productCardEvents.productChanged, ...)
  ),
);

@Component({
  // ...
  providers: [provideDispatcher(), ProductCardStore]
})
export class ProductCard {
  readonly product = input<Product>();
  readonly store = inject(ProductCardStore);
  readonly dispatch = injectDispatch(productCardEvents);

  constructor() {
    effect(() => {
      this.dispatch.productChanged(this.product());
    });
  }
}

With scope approach, this is not possible.

markostanimirovic avatar Aug 08 '25 14:08 markostanimirovic

@markostanimirovic that is an excellent reason for provideDispatcher πŸ‘πŸ»

michaelbe812 avatar Aug 08 '25 18:08 michaelbe812

πŸ‘† RFC description have been updated with a new proposal on how to skip the current scope and dispatch an event to the parent or root scope.

markostanimirovic avatar Aug 21 '25 08:08 markostanimirovic

How do I pass the action payload here?

this.dispatcher.dispatch(myEvent(withScope('root'))) 

I think I still prefer how it was before, with the options as a second param of dispatch

this.dispatcher.dispatch(myEvent(), { scope: 'root' }) 

rosostolato avatar Aug 21 '25 10:08 rosostolato

How do I pass the action payload here?

this.dispatcher.dispatch(myEvent(withScope('root'))) I think I still prefer how it was before, with the options as a second param of dispatch

this.dispatcher.dispatch(myEvent(), { scope: 'root' })

How would we then dispatch events to parent or root scope in effects?

The proposal is changed with the goal to provide unified approach for both, regular dispatches and effects.

This syntax:

this.dispatcher.dispatch(myEvent(withScope('root')));

should be rarely used anyway. The injectDispatch approach is preferred in components:

this.dispatch.myEvent(withScope('root'));

markostanimirovic avatar Aug 21 '25 10:08 markostanimirovic

I see what you mean, if you add scope to the event object, you can tell the scope to the effect observable.

Another option is to wrap the event in another another object with this util function:

  loadUsers$: events.on(opened).pipe(
    switchMap(() => {
      return usersService.getAll().pipe(
        mapResponse({
          next: (users) => loadedSuccess(users),
          // dispatching only failure event to the parent scope
          error: (error: string) => scoped('parent', loadedFailure(error)),
        }),
      );
    }),
  ),

That would return { event: ..., scope: 'parent' } and the dispatcher would know how to handle it.

rosostolato avatar Aug 21 '25 10:08 rosostolato

It can be called withScope instead of scoped to keep consistency.

That can be used directly in the dispatcher too

this.dispatcher.dispatch(withScope('root', myEvent()));

Looks better to me, what do you think?

rosostolato avatar Aug 21 '25 10:08 rosostolato

Thanks @rosostolato! I'll think about it.

markostanimirovic avatar Aug 21 '25 10:08 markostanimirovic

We have the injectDispatch too... Well, we can add a modifier here:

readonly dispatch = injectDispatch(myEvents);

myMethod(payload: string): void {
  this.dispatch.withScope('root').myEvent(payload);
}

I understand that this proposal adds 3 different way of doing the same as the original withScope was doing, but idk... just feel weird to me touch the event params

rosostolato avatar Aug 21 '25 11:08 rosostolato

Just wanted to ask, are there any updates about this? It feels like a "core" functionality for the nature of Signal stores. Not meant in a bad way, just looking really forward to this πŸ˜„

NicolasMaas avatar Sep 18 '25 08:09 NicolasMaas

@NicolasMaas Support for scoped events will be released in v21. https://github.com/ngrx/platform/pull/4997

markostanimirovic avatar Nov 16 '25 02:11 markostanimirovic