Skip to main content

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:

  1. 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.
  2. 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.
  3. 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 with createSelector from redux-toolkit, and then memoise it to provide our own custom equality check function as a second paramater to the useSelector 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)
);