-
-
Notifications
You must be signed in to change notification settings - Fork 2k
Description
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