This series explores how we can keep code declarative as we adapt features to progressively higher levels of complexity.
Level 7: Multi-Store Selectors
Now imagine we need to disable the blackout button if all the colors are already black. So now we need state from all the stores.
If we were using a state management library like NgRx, this would be easy. We would just create another selector like this:
const selectAllBlack = createSelector(
favoriteColors.selectAllAreBlack,
dislikedColors.selectAllAreBlack,
neutralColors.selectAllAreBlack,
(favoriteAllAreBlack, dislikedAllAreBlack, neutralAllAreBlack) =>
favoriteAllAreBlack && dislikedAllAreBlack && neutralAllAreBlack,
);
But in our case, selectors were defined in an adapter, detached from any particular state. It is when we create our little stores that they attach to actual state. So, we need a way to get those selectors out of our stores and create new selectors with them. And having them wrapped in observables again would be nice. So, it's as if we want another store, one that joins together the original stores:
colorsStore = joinStores({
favorite: this.favoriteStore,
disliked: this.dislikedStore,
neutral: this.neutralStore,
})({
allAreBlack: s =>
s.favoriteAllAreBlack && s.dislikedAllAreBlack && s.neutralAllAreBlack,
})();
Our new selector has access to each store's selectors, each prefixed with the object key we used in the first object when passing its store in. What is that s
? I chose to abbreviate to s
because that object represents both derived state and selector names, since they are the same. And because s
is short and I like typing less 🤷. With only 1 selector, we are using more lines of code than createSelector
, but when we have 2+ selectors, this approach is much less code.
Internally we can use a proxy to see which selectors are being accessed, and construct input selectors dynamically. If the first allAreBlack
selector never returns true
, we never even need to check the others. The optimization is only possible because we can assume the selector is a pure function.
In the template we can use it like this:
<button
class="black"
(click)="blackout$.next()"
[disabled]="colorsStore.allAreBlack$ | async"
>Blackout</button>
The button now becomes disabled after it's clicked:
And when you change one of the colors, the button goes back to enabled:
Our selector depends on selectors from inside the stores, but ultimately those were defined in adapters. Adapters are easy to test because they don't depend on Angular, or stores, or anything except for utilities and possibly other adapters. The logic inside of them is completely independent from specific state or stores anywhere. Wouldn't it be nice if we could define our new selector in its own adapter, and just refer to it in our joinStores
function call?
We could have a joinAdapters
function with similar syntax to that of joinStores
:
const colorsAdapter = joinAdapters<AllColorsState>({
favorite: colorAdapter,
disliked: colorAdapter,
neutral: colorAdapter,
})({
allAreBlack: s =>
s.favoriteAllAreBlack && s.dislikedAllAreBlack && s.neutralAllAreBlack,
})();
// ...
colorsStore = joinStores({
favorite: this.favoriteStore,
disliked: this.dislikedStore,
neutral: this.neutralStore,
})(colorsAdapter.selectors)();
You know what else is nice about this pattern? If, for any reason, we decided to have a single store instead of 3 separate stores, we could now use that joined adapter by itself:
colorsStore = createStore(['colors', initialState, colorsAdapter], {
setFavorite: this.favorite$,
setDisliked: this.disliked$,
setNeutral: this.neutral$,
setAllToBlack: this.blackout$,
});
Where did this new setAllToBlack
state change come from? Not any individual adapter. Before, we had a single source that plugged into 3 separate setAllToBlack
state reactions, one for each store. Similarly, when we join adapters, we have a way to specify efficient state changes that involve multiple adapters:
const colorsAdapter = joinAdapters<AllColorsState>({
favorite: colorAdapter,
disliked: colorAdapter,
neutral: colorAdapter,
})({
setAllToBlack: {
favorite: colorAdapter.setAllToBlack,
disliked: colorAdapter.setAllToBlack,
neutral: colorAdapter.setAllToBlack,
},
})({
allAreBlack: s => s.favoriteAllAreBlack && s.dislikedAllAreBlack && s.neutralAllAreBlack,
})();
This is the same amount of code as when the stores were separate. Unfortunately, the syntax has to be different. Now, instead of the button calling blackout$.next()
, it will call colorsStore.setAllToBlack()
, and instead of 3 separate stores reacting to that source, we have a single state reaction specifying 3 inner state reactions. So the syntax is sort of inside-out compared to having 3 separate stores.
Which way is better? Separate stores or a combined store?
I don't know yet. So, I felt it was important to have the syntax be as similar as possible so if there was a situation where one became preferable over the other, it would be easy to change at that time.
The goal of this series was to explore how to avoid syntactic dead ends throughout the whole process of increasing reactivity. Is there a better syntax than this?
I think this is beautiful, but I'd love to hear your thoughts. I can still change things. StateAdapt 1.0 hasn't been released yet. This series is a way for me to solidify the syntax to prepare for that release.
I'm trying to design ideal syntax for 100% declarative state management. However, I also recognize that we need to know how to deal with imperative APIs. That's the next article in this series. After that, we'll take a step back and look at how to achieve as declarative as possible state management given the current ecosystem of libraries in Angular, and the fact that my favorite syntax (StateAdapt) isn't ready for production yet.
Top comments (0)