In a previous article, Managing different slices of the same NgRx state, we had to overcome some hurdles to slice up the state in order to provide the NgRx Store and Effects to same components but with a different id. Triggered by Michael Hladky's work on @rx-angular/state and Alex Okrushko's RFC to add a ComponentStore to NgRx, it got me thinking if the NgRx Store could also serve as a local store. And spoiler alert... it can!
If you've been using NgRx, you know that state is being managed with reducer functions and that each slice of the state has its own reducer. A typical state tree can be displayed as follows:
Each reducer is registered by using the
StoreModule.forFeature() import functions when the
ngModule is defined. But this isn't good enough for component state because the components will be created at runtime.
To be able to use the global store, the component needs to dynamically create and claim a slice of the global state when it's rendered.
To create a new slice we can use the
ReducerManager. The important part here is to use a unique key for the slice, otherwise a component will override another component's state. The component name is used to generate the key so it can easily be looked up while debugging, plus a unique id.
The result is that when a component is created, it will create its own slice in the global store. For example, if three components are created, it will result in the following state slices.
Because this is component state, its lifetime can be bound to the component lifecycle.
When the component is destroyed the application doesn't need its state anymore, thus it can be cleaned up.
OnDestroy lifecycle hook is used to remove the state slice which is equal to the unique component name.
With just these few lines of code, a local component store is created, but we're not finished yet.
An important, or maybe the most important, aspect of component state is that the component knows when to update and when it does not have to.
If you're familiar with NgRx you already know that all actions are dispatched to all reducers.
Via the action's identifier, the
type property, a reducer knows if it should update its state.
Because the reducer is now created inside a component this means that when there are multiple of the same components rendered, all the component reducers receive the same action when one component dispatches an action and they all update their state.
This is not the desired result. When a local action is dispatched it needs to be aware of its context (the component). Via this context the reducer can filter out any actions from a different context (component), and if you want it can also let global actions pass through.
Creating an action remains the same, the
createAction function is used to create an action factory function.
To add the component's context on the action, a
meta tag is added which contains the unique name of the component.
To keep things DRY, a
dispatch function is added to the component.
It acts as a wrapper to tag actions that need to be aware of the component context, before the action is sent to the global store.
When an action reaches the global store it looks as follows.
Now that the action is aware of the component context, the reducer needs to be made smarter. When it receives an action, the action's meta tag needs to be checked to verify if it's an action for its context.
Therefore, the reducer is wrapped inside another reducer and the parent reducer will invoke the component reducer when it receives a local action from the same component, or a global action (if you want to). When the reducer receives an action from another local component, it just returns the current state because it's not interested in this action.
It's also possible to create a function and short-circuit the reducer just to set a new state value.
State would be useless if it couldn't be selected.
There's nothing special going on here as selectors are just pure function that retrieves state and returns a projection of the state.
The main piece of a component selector is to receive the correct slice of the state, which is its own slice.
For this, the
selectFeatureSelector function can be used.
The component's unique name is used to select the top-level slice of the component state.
To create selectors, the
componentStateSelector is being passed as an argument to receive the correct slice.
Because it's using the same API as the global selectors, a selector can be composed with other selectors.
To read the data, it's also needed to use the
The only difference with before is that the selectors are now created within the component because they are all based on the
What would NgRx be without its Effects, right?
Before Effects can be implemented inside components we need to know how these are registered.
NgRx Effects looks for properties in a class that are created with the
createEffect function, or with the
Both functions mark these property with a metadata key.
When the Effect class is registered via the
EffectsModule.forRoot() or the
EffectModule.forFeature() function it looks for these marked properties, and they will be subscribed to.
Like reducers only registering Effects when a module is bootstrapped isn't good enough, the Effects inside a component need to be created after the component is rendered.
To do this the
EffectSources subject can be used to add an Effect class dynamically.
Because the current component instance is passed (via
this) to the
addEffects function, all Effects that are instantiated in the component will be automatically subscribed to.
By default, only one instance of the same Effect class will be subscribed to. This is done to prevent the same API calls when the same Effect is registered in multiple modules. This default behavior means that only the Effects of the first component will be subscribed to. This again, is not what we want for our local component state.
In order to distinguish the Effects when multiple of the same components are created, the
OnIdentifyEffects lifecycle hook is used. The component already has a unique name, so it can be re-used to create a unique Effect. Because all Effects have a unique name, they will be all subscribed to.
To bind the lifetime of an Effect to the component's lifetime, a second Effect lifecycle hook,
OnRunEffects is used.
Inside the hook, the Effect subscriptions will be destroyed when the component is destroyed.
The last piece to complete the puzzle is an RxJS operator to filter out actions from other components.
It can be compared to the
ofType operator, but checks the meta tag of the component to the component name.
If this isn't used, it means that the action will trigger the Effects for all of the rendered components.
The check inside this operator is the same check as within the reducer.
Or both the
forThisComponent operators, could be used together in a custom
If everything is put together, an Effect looks as follows.
It's also possible to listen to global actions if the
forThisComponent operator is left out.
And just like global NgRx Effects, an Effect can also use different sources.
This was a fun experiment for me, and I hope you learned something new about NgRx. From my point of view, it certainly showed how flexible NgRx can be.
The biggest drawback of this solution is that it has a dependency on
@ngrx/effects, whereas both Michael's and Alex's solutions work independently from the current NgRx packages. With it, you are able to use it with the familiar NgRx packages but also with other state management libraries like NGXS and Akita, with plain RxJS Subjects, or even without any state management.
While we're at the topic of a component store, there's currently an RFC open to adding a new component store package for managing local component state into the NgRx platform and we're looking for feedback, so take a look and speak up about the parts you like and dislike, or remain silent forever.
The code in this example can be abstracted to make it reusable. To make things complete, init and destroyed actions could also be dispatched to represent the component's lifecycle. For a working example see the cleaned up StackBlitz below, it's a reworked example based on the demo that Alex has made. In the example, also make sure to take a look at the redux DevTools to have an overview of what is happening.
Please consider supporting me if have you enjoyed this post and found it useful: