Advanced NgRx: Building a Reusable Feature Store
As an angular application grows in scale and complexity, so often is the need for state management to help simplify its many moving parts. What often occurs is an increase in the amount of boilerplate involved in managing many feature states, and stores.
Increase in store size can often lead to repetitive patterns, much like in poor component design. However, if state is written well, an application can utilize a less common pattern- a reusable feature store- as we attempt to eliminate much of this boilerplate headache.
The following proposes a pattern where we’ll create a pattern for properly consolidating several stores, gain consistency across similar features with a reusable store, and address any pitfalls in this pattern.
When to use consolidating state management patterns
Before we get too deep into consolidating, we should first stop, and assess, when and why we're attempting this task.
Why consolidate in the first place?
- Reduce repeating patterns in feature design.
- Increased maintainability of similar features.
- Quicker iterations.
- A better shared abstracted layer that can be extended as needed for edge cases.
- Similar or extensible UI or data stores.
When should you consider consolidating?
A hard to answer question, but one that's satisfied by having really good foresight, a roadmap of how an app's features will work, or a set of existing features that may need a few iterations to bring them closer in overall design.
What this means for both approaches is that a feature can be initially designed similarly to another, or be made to function similar to another to make it DRY-er (Don't Repeat Yourself) later on.
A closer look at the store pattern
Now that there's a direction and reason for our efforts, let's look at the pattern using a mock version of photo website - Unsplash - to build out a data store.
Let's say we have several states that look like this:
export interface WallpapersState {
photos: Photo[];
isLoading: boolean;
}
export interface PeopleState extends Photo {
photos: Photo[];
isLoading: boolean;
}
...
// rest of states for remaining photo types
Fortunately for us, our photo assets follow a very similar pattern. This state is simple as it will contain photos in a simple state. However, we can squash these into one state like this:
export interface PhotoState {
photos: Photo[];
isLoaded: boolean;
isLoading: boolean;
}
export interface PhotoTypeState {
[assetType: string]: PhotoState;
}
And with that simple bit of code, we've opened up the possibility to reuse a data store per photo type into a single store!
For example, a website like Unsplash could use several strategies to fetch and display photos like filtering by photo type on navigation, or pre-fetching chunks of photo types in a resolver. Imagine if each photo type acted as an individual store? That would be a lot of stores to maintain!
Building the new state and reducer
As this new feature store is built, keep in mind that typings become tricky when you begin to use an indexed interface as a type.
Typing pitfalls
Typescript doesn't like when we add more properties onto indexed interfaces because it assumes that we'd only be adding properties that follow the initial type we assigned to the index property. In this case Photo[]
.
For example, this will work:
export interface PhotoTypeState {
[assetType: string]: PhotoState;
}
But this won't, because PhotoType
on selectedPhotoType
doesn't overlap in types:
export interface PhotoTypeState {
selectedPhotoType: PhotoType; // for selecting the right photo store
[assetType: string]: PhotoState;
}
To overcome this, we can use an Intersection Type like so:
export interface PhotoTypesState {
[photoType: string]: PhotoState;
}
export type State = PhotoTypesState & {
selectedPhotoType: PhotoType;
};
Defining the reusable part of state
We want some amount of flexibility in our store, but we have to tell the new store what features we intend to keep similar in pattern. One way we could do this is by creating a dictionary of features. Building the initial state for the photo types could look like:
// Dictionary referencing the phtoto types as features
export enum PhotoType {
Promos = "Promos",
Wallpapers = "Wallpapers",
People = "People",
Nature = "Nature",
Architecture = "Architecture",
Misc = "Misc",
}
// Initial state for each photo type
export const INITIAL_PHOTO_TYPE_STATES: PhotoTypeState = Object.keys(
PhotoType
).reduce((acc, val) => {
acc[PhotoType[val]] = [];
return acc;
}, {});
And initial state for the reducer:
/**
* Initialize the default photo type.
*
* NOTE: we have to assign an initial value in this
* example's load strategy so our selector doesn't read
* the state as `undefined`.
*
* Because we used an indexed type, we would have to
* force type properties to `any` to avoid type conflicts.
*
* To get around an initial value and use `null`, change
* your load to one that makes sense for you app.
*/
export const INITIAL_PHOTOS_STATE: PhotosState = {
selectedPhotoType: PhotoType.Promos as any,
...INITIAL_PHOTO_TYPE_STATES,
};
Another win for reusable store pattern
Maybe you noticed already, but what if each of the individual states used entities? We could help ourselves, and speed up our dev time some more with the adapter methods and selectors exposed per PhotoType
. Our biggest win here comes from the fact that we can still use NgRx Entities even in these seemingly nested states of state. The above piece changes like so:
export interface PhotoEntitiesState extends EntityState<PhotoState> {
// additional entity state properties
}
export interface PhotoTypeEntitiesState {
[photoType: string]: PhotoEntitiesState;
}
...
const adapter: EntityAdapter<PhotoState> = createEntityAdapter<PhotoState>({
// additional entity state properties
});
And give the state slices an initial state:
export const INITIAL_PHOTO_TYPE_STATES: PhotoTypeState = Object.keys(
PhotoType
).reduce((acc, val) => {
acc[PhotoType[val]] = adapter.getInitialState({});
return acc;
}, {});
Tying it together with the reducer and selectors
Now that we have the state accurately defined. We can access the select slices of our single store using the selectedPhotoType
property:
export const photosReducer = createReducer(
INITIAL_PHOTOS_STATE,
on(PhotoActions.loadPhotoSuccess, (state, { photos }) => ({
...state,
[state.selectedPhotoType]: {
...state[state.selectedPhotoType],
photos,
},
}))
);
And for the selectors:
export const photosState = createFeatureSelector("photos");
export const selectActivePhotoTypeState = createSelector(
photosState,
(state) => state[state.selectedPhotoType]
);
export const selectAllPhotos = createSelector(
selectActivePhotoTypeState,
(state) => state.photos
);
Again, we can use the entity adapter and leverage entity methods and selectors. Full Code Example Here.
Conclusion
When working with state management stores in NgRx, it shouldn't be a mess of maintaining boilerplates and changes that affect several data sources with the same store shape. As developers, we want to think of future-proofing strategies that abstract enough, yet still help us understand exactly how the application works. By leveraging a reusable store strategy, we gain consistency with like features, a level of abstraction and sharability between like features, with the ability to extend for edge cases, and easier maintenance.