`@ngrx/signals/events`: Provide Support for Scoped Events
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
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: [].
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.
That would perfectly fit my needs, thanks! π
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
}
}
)
)
)
}
I don't see anything that would impact negatively the core functionality and that's exactly what I need, thank you!
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 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.
@michaelbe812 How would
scopebe handled in the store? Can you provide additional code snippets of potential usage?With
provideDispatcherthere is no additional effort -withReducerandwithEffectsare 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 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 that is an excellent reason for provideDispatcher ππ»
π 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.
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 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'));
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.
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?
Thanks @rosostolato! I'll think about it.
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
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 Support for scoped events will be released in v21. https://github.com/ngrx/platform/pull/4997