Redux Selectors
There is a few important considerations to make when creating selectors, particularly within components. When a component is rendered, selectors will be evaluated, and if their returned value has a new reference, it can trigger a component re-render, even if the value hasn't really changed.
// These don't need to be memoised because they don't manipulate the state and will return a stable reference.
const selectNextToGoRaces = state => state.races.nextToGoRaces;
const selectRaceById = (state, raceID) => state.races[raceID];
// This also doesn't need to be memoised because the returned value is a primitive.
const selectTotalGamePoints = (state, gameID) => (
state.games[gameID].rounds.reduce((points, round) => round.points + points);
)
// Should be memoised - each call will return a new reference.
const selectPlayersWithHighPoints = (state, gameID) => (
state.games[gameID].players
.filter(player => player.points > 10)
.map(player => player.name)
)
Selectors are called with the entire redux store as an argument whenever the component renders. When the action is dispatched, every selector will be re-evaluated and if any result is different it will cause an update and trigger a re-render.
In the above examples:
- The first group of selectors will return a consistent reference to their respective fragments of the store. If those values have not changed the selector won't trigger a re-render.
- The second example will return a consistent value if the applicable game has not changed. While the final value is being calculated based off the value return from the store, because the result is a primitive the equality check
===
will only trigger a re-render if that equality check fails. - In the third example the result of the selector logic will return a non-primitive value. This means that even if the value looks the same, the strict equality check will see the new value has a different reference and will return
false
, triggering a render. To avoid this we can use an enhanced custom selector withcreateSelector
fromredux-toolkit
, and then memoise it to provide our own custom equality check function as a second paramater to theuseSelector
hook.
Using createSelector
The selectors in the above example don't maintain any internal state, whereas selectors created with createSelector
do manage their own internal state. They do this in order to memoise the return value of the selector itself.
In cases where a selector is used in multiple component instances and depends on the component's props, you need to ensure that each component instance gets its own selector instance.
In the following code we look at how we might use the previous selectors to create selectors with createSelector
while ensuring they're as optimised as possible.
const makeSelectNumberOfEntrantsFromRace = () =>
createSelector(selectRaceById, (race) => race.entrants.length);
const makeSelectPlayersWithHighPoints = () =>
createSelector(selectPlayersWithHighPoints, (players) => {
// perform calculations
return finalPlayersResult;
});
const Component = ({ raceID }) => {
/* EXAMPLE 1 */
// This selector won't trigger a re-render because the returned value is a primitive.
// BUT the calculations will be performed each render because the selector isn't memoised.
const notMemoisedNumberOfEntrants = useSelector((state) =>
makeSelectNumberOfEntrantsFromRace()(state, raceID)
);
/* EXAMPLE 2 */
// Here we are no longer performing the calculations on each render because the selector has been memoised.
const selectNumberOfEntrants = useMemo(
makeSelectNumberOfEntrantsFromRace,
[]
);
const memoisedNumberOfEntrants = useSelector((state) =>
selectNumberOfEntrants(state, raceID)
);
/* EXAMPLE 3 */
const selectPlayersWithHighPoints = useMemo(
makeSelectPlayersWithHighPoints,
[]
);
const memoisedPlayersWithHighPoints = useSelector((state) =>
selectPlayersWithHighPoints(state, gameID)
);
};
Example 1: The selector notMemoisedNumberOfEntrants
doesn't receive any of the benefits of createSelector
because whenever the component re-renders a new selector will be created, meaning the internal selector state isn't maintained. This is basically the same as not using createSelector
at all. In a case where this selector is shared across components, any changes to raceID
also has the potential to trigger re-renders because this selector instance has been shared.
Example 2: In this example memoisedNumberOfEntrants
returns a primitive value, but an improvement is observed in that the selector won't rerun it's calculations as long as the provided input is the same. Here, reselect
handles memoisation and remembers the last returned value, returning that immediately. When an action is dispatched to the store and every selector is evaluated, the memoised selector will perform a strict equality check and return true meaning a re-render won't be triggered. There is also the added benefit of not running unnecessary calculations during the render phase.
Example 3: In this example we can see how a selector should be properly memoised when returning a non-primitive value (in this instance an array). It is highly recommended to memoise selectors returning non-primitive values otherwise any dispatched action can trigger a re-render and hurt performance. If the makeSelectPlayersWithHighPoints
selector was not properly memoised, the caching mechanism used by createSelector
would not work, the value would be recalculated, and the returned value would have a different reference. If the selector is shared across components, any changes to the props that it depends on (in this case raceID
) we are preventing unnecessary re-renders.
To help illustrate here is an example of how incorrect selector memoisation can hurt performance:
const makeGetGroupFromMarketID = () =>
createSelector(
// logic
return selectorLogic()
);
const useLiveRacingState = (liveMarketID) => {
const availableProductGroups = useSelector((state) =>
makeGetGroupFromMarketID()(state, liveMarketID)
);
return useMemo(() => {
// logic
}, [availableProductGroups]);
};
In this example each render of useLiveRacingState
will create a new instance of the selector, meaning that even if availableProductGroups
is the same each time, it will have a new reference because the memoisation applied by createSelector
isn't happening. As a result the potentially expensive calculations done inside useMemo
are re-running unnecessarily each render, and any components or hooks depending on that return value is also re-running.
// Memoise the selector
const memoisedSelector = useMemo(makeGetGroupFromMarketID, []);
const availableProductGroups = useSelector((state) =>
makeGetGroupFromMarketID(state, liveMarketID)
);