NgRx selectors promise performance gains via memoization. However, we must take care when defining our selectors otherwise we may fail to benefit from memoization! In fact we may inadvertently degrade the performance of our application.
NgRx Selectors
If you are not familiar with NgRx Selectors then check out this talk from Brandon Roberts on selectors or the docs. They are basically a way of extracting data from your Store
.
Next let's see how easy it is to fall into this performance trap!
Counter Application
To demonstrate the performance trap we will use a counter app. You can experiment with the code in this StackBlitz which complements this post.
There are two counters and a text box. We display the current value of each counter and the total of all counters.
Our state has the following interface.
export interface CounterState {
counter1: number;
counter2: number;
name: string;
}
export interface BusyState {
//lots of updates happen here!
}
export interface RootState {
counter : CounterState;
busyState: BusyState;
}
Note that we have two feature slices, counter
and busyState
. busyState
, as the name suggests, receives a lot of updates.
Calculating the Total
As we do not want to store derived state in our store, we will need to calculate the total on the fly. There are a few ways to calculate the total to be displayed in our template. Each has its own performance characteristics which we will now examine.
Adding two numbers is a trivial operation but for the sake of this post let's imagine it is a very expensive computation which we must minimise calculating.
Calculate Total in the Component
We can calculate the total directly in our component using the injected store and the select
operator.
// Component
constructor(private store: Store<RootState>){}
this.total$ = store.pipe(select(state =>
state.counter.counter1 + state.counter.counter2)
);
However, with this approach the calculation will be re-run for every change to our state. That includes every change made to BusyState
which are totally unrelated and will never change the value of the total! This is really bad for our performance so let's see if we can do better.
Calculate Total in Reducer with a Selector
As you may have guessed we are going to use selectors to improve the performance. We do this by using the creator functions, as described by Tim Deschryver, from @ngrx/store
. Using these creator functions we can move the total calculation out of our component and into our reducer.
// Reducer
import { createSelector, createFeatureSelector } from "@ngrx/store";
const featureSelector = createFeatureSelector<CounterState>("counter");
export const getTotal = createSelector(
featureSelector, s => s.counter1 + s.counter2
);
We take as input our feature slice and return counter1 + counter2
to give us an observable stream of the total. We then use this in our component to display the total.
// Component
this.total$ = store.pipe(select(getTotal));
Using this selector means that our total calculation is only run on changes to the counter
feature slice. This is a great improvement as no longer is it being re-run for unrelated changes to BusyState
. But let's not stop there we can do even better!
Understanding Memoization
At this point it is important to understand how the memoization of selectors work as we are still not taking full advantage of it.
Lets go back to the docs for selectors.
When using the createSelector and createFeatureSelector functions @ngrx/store keeps track of the latest arguments in which your selector function was invoked. Because selectors are pure functions, the last result can be returned when the arguments match without re-invoking your selector function. This can provide performance benefits, particularly with selectors that perform expensive computation. This practice is known as memoization.
The important part here is that @ngrx/store
keeps track of the latest input arguments. In our case this is the entire counter
feature slice.
export const getTotal = createSelector(
featureSelector, s => s.counter1 + s.counter2
);
To see why we can do better let's start updating counter.name
via our text input. On every stroke an action is dispatched to update the name
. On each update our total is being recalculated because it is part of the same feature slice.
Calculate with Composed Selectors
Using what we learnt from the docs we will re-write our getTotal
selector to ensure that it is executed only when its own arguments change. We do this by composing it of a getCounter1
selector and a getCounter2
selector. These counter selectors will only emit new values when the specific counter updates. This in turn means that the arguments to our getTotal
selector only change when the value of one of the counter changes.
// Reducer
export const getCounter1 = createSelector(
featureSelector, s => s.counter1
);
export const getCounter2 = createSelector(
featureSelector, s => s.counter2
);
// Composed selector
export const getTotal = createSelector(
getCounter1, getCounter2, (c1, c2) => c1 + c2
);
With this setup changes to the counter.name
no longer cause the total to be recalculated! We finally are making full use of memoization and have ensured we only run the total calculation when we absolutely have to. This is the power of selector composition.
Real life scenario
While our demo app is too small to have performance issues, these principles can be applied with great effect to large applications.
In one app that I worked on we had a number of interdependent dropdowns, i.e updating the selection in one would filter the available options in the others. This was driven by selectors all working off the root store. I was tasked with investigating the sluggishness of these selectors. The first thing I did was start logging out every time each selector ran. It was hundreds of times!!
This is when I discovered the importance of composing your selectors. Making the changes, as outlined above, brought the number of selector calls down from hundreds to just a handful. The performance improvement was dramatic and the selectors were no longer sluggish.
Final Thoughts
If you are doing anything computationally expensive in your selectors then you want to ensure that you only run that code when you absolutely have to. Composing your selectors is one technique that enables you to achieve this and protect the performance of your application.
Follow me on Twitter @ScooperDev or Tweet about this post.