Let’s focus on the library Reselect , which handles memoization of Redux (and others) selectors. It can be useful when we want to do some selectors with business logic and costly to execute. Or when you want to keep a same reference (when no needs to reprocess) not to re-render when we use PureComponent or memoized component with React.memo.
Let’s take the example of a Redux state where we keep the user session:
const reduxState = {
user: {
id: 1,
firstName: 'Bob',
lastName: 'Sponge',
email: 'bob.sponge@gmail.com',
},
};
If I want to select the user to display its informations in a dedicated component named User
:
import { selectUser } from './userSelector';
import { useSelector } from 'react-redux';
function User() {
// I have written the lambda to see that useSelector give us the state
// Otherwise we could only write useSelector(selectorUser)
const user = useSelector((state) => selectUser(state));
return (
<div>
<div>First name: ${user.firstName}</div>
<div>Last name: ${user.lastName}</div>
</div>
);
}
export default User;
The selector selectUser
is:
export const selectUser = (state) => state.user;
Let’s imagine we want to implement a books management site, where we have a slice of the state which handles autors and another one with books:
const reduxState = {
authors: [
{
id: 1,
firstName: 'John',
lastName: 'Green',
},
{
id: 2,
firstName: 'Lauren',
lastName: 'Weisberger',
},
],
books: [
{
id: 1,
title: 'The devil wears prada',
authorId: 2,
},
{
id: 2,
title: 'The fault in our stars',
authorId: 1,
},
],
};
If I want to get the list of books with the author with the data structure:
const books = [
{
id: Number,
author: String,
title: String
},
...
]
It can be useful to do a memoized selector with reselect. Reselect exports a function createSelector
which takes as first parameters the dependencies
functions et as second (or last see note below) the result function which will return the result after processing. These dependencies functions return
objects which are injected as parameters to the result function (in the same order as the defined dependencies functions). Here is an example:
import { createSelector } from 'reselect';
// Should not be mutated
const EMPTY_OBJECT = {};
const EMPTY_ARRAY = [];
const selectAuthors = (state) => state.authors;
const selectBooks = (state) => state.books;
// Depends on the selectAuthors function
const selectAuthorsById = createSelector(
selectAuthors,
(authors) => {
return authors.reduce((acc, author) => {
return {
...acc,
[author.id]: author,
};
}, EMPTY_OBJECT);
},
);
function getAuthorName(author) {
if (!author) {
return '';
}
return author.firstName + ' ' + author.lastName;
}
// Depends on the selectAuthorsById and selectBooks functions
export const selectBooksList = createSelector(
[selectAuthorsById, selectBooks],
(authorsById, books) => {
return books.reduce((acc, book) => {
const authorName = getAuthorName(
authorsById[book.authorId],
);
return [
...acc,
{
id: book.id,
title: book.title,
author: authorName,
},
];
}, EMPTY_ARRAY);
},
);
The result using this selector is:
import { selectBooksList } from './bookSelector';
let reduxState = {
authors: [
{
id: 1,
firstName: 'John',
lastName: 'Green',
},
{
id: 2,
firstName: 'Lauren',
lastName: 'Weisberger',
},
],
books: [
{
id: 1,
title: 'The devil wears prada',
authorId: 2,
},
{
id: 2,
title: 'The fault in our stars',
authorId: 1,
},
],
};
console.log(selectBooksList(reduxState));
/*
[
{
id: 1,
title: "The devil wears prada",
author: 'Lauren Weisberger'
},
{
id: 2,
title: 'The fault in our stars',
author: 'John Green'
}
]
*/
reduxState = {
...reduxState,
otherData: {},
};
// We do not re-execute the selectors selectBooksList and selectAuthorsById.
console.log(selectBooksList(reduxState));
As long as authors
and books
have the same references, we do not reprocess the result functions of selectBooksList
and selectAuthorsById
. In
our example, we only process the data at the first call of selectBooksList
.
But, how does Reselect work under the hood?
Before starting, if you do not feel comfortable with function memoization, I advise you to read the article Javascript memoization .
The first methods of the file index.js
:
defaultEqualityCheck
: function to compare two values with strict equalityareArgumentsShallowlyEqual
: method to compare with shallow equal, the default comparison method isdefaultEqualityCheck
but is configurable.defaultMemoize
: memoization function of the last value, it takes assecond parameter the comparison method which is by defaultdefaultEqualityCheck
The method defaultMemoize
is exported by reselect and can be used in projects using the library.
Previously, we have seen that we can pass dependencies with 2 differents ways. To get the right dependencies functions in the 2 cases, we are going to
implement a method getDependencies
which will take an Array of functions as parameters:
- either there is a single element and it’s an Array of functions
- or directly an Array of function
This method will return an Array of functions.
function getDependencies(funcs) {
// If the first element is an Array, we return this one
// In this case the user has passed directly an Array with the dependencies functions
// createSelector([firstDependency, secondDependency], resultCallback)
// Otherwise it's a simple array of functions as elements
// createSelector(firstDependency, secondDependency, resultCallback)
const dependencies = Array.isArray(funcs[0])
? funcs[0]
: funcs;
// We check that all elements are functions otherwise we return an Error
if (
!dependencies.every((dep) => typeof dep === 'function')
) {
const dependencyTypes = dependencies
.map((dep) => typeof dep)
.join(', ');
throw new Error(
'Selector creators expect all input-selectors to be functions, ' +
`instead received the following types: [${dependencyTypes}]`,
);
}
return dependencies;
}
From now we know how to get the dependencies functions which will send parameters fo our result function (the last parameter pass to createSelector
).
The step will be the following:
- get the result function (the last parameter in all case)
- memoizes this method
- get depedencies functions
- return a function in which: — we execute depedencies functions to get an Array of parameters — we pass these parameters to the memoized function
export function createSelector(...funcs) {
// We get the result function
const resultFunc = funcs.pop();
// We get the dependencies functions Array
const dependenciesFuncs = getDependencies(funcs);
// We memoize the result function
const memoizeResultFunc = defaultMemoize(resultFunc);
return function () {
const parameters = dependenciesFuncs.map((func) =>
func(...arguments),
);
return memoizeResultFunc(...parameters);
};
}
This way to code the function createSelector
could be possible. However the real implementation
is different:
- the memoization function is configurable
- the number of times we execute the result function is counted
- we memoize the returned function not to uselessly re-execute it. For example (with React), while the component using a reselect selector is re-render only because the parent is re-render (no change of redux state or/and props).
- the functions are not executed by spreading the parameters but using
apply
for performances reasons: #194 - the implementation does not use
Array#map
also for performance reasons (see the PR above)
// We can configure the memoization method, and pass option for this one
export function createSelectorCreator(
memoize,
...memoizeOptions
) {
return (...funcs) => {
const resultFunc = funcs.pop();
const dependenciesFuncs = getDependencies(funcs);
let recomputations = 0;
const memoizeResultFunc = memoize(
function () {
recomputations++;
return resultFunc.apply(null, arguments);
},
...memoizeOptions,
);
// Optimization not to reprocess when arguments are the same in shallow equals
// In this case we use the memoization method without options not to change the default behavior
const selector = memoize(function () {
// In the current implementation, no use of Array#map for "performances" reasons
const parameters = [];
const length = dependenciesFuncs.length;
for (let i = 0; i < length; i++) {
parameters.push(
dependenciesFuncs[i].apply(null, arguments),
);
}
return memoizeResultFunc.apply(null, parameters);
});
// In the real implementation, we can get the result function
// and the dependencies functions from the selector
selector.resultFunc = resultFunc;
selector.dependencies = dependenciesFuncs;
// Function to get the number of re-reprocess from the selector
selector.recomputations = () => recomputations;
selector.resetRecomputations = () =>
(recomputations = 0);
return selector;
};
}
export const createSelector =
createSelectorCreator(defaultMemoize);
Let’s analyze the performances gains of optimization which have been made, for us in 2021?
In all examples, I will use the following closure to measure durations:
function startTimer() {
const start = process.hrtime();
// Return the duration in milliseconds
return function endTimer() {
const end = process.hrtime(start);
return end[0] * 1000 + end[1] / 1000000;
};
}
Array#map
vs foreach loop + push
The both implementations I will test are:
const numberElements = [1, 2, 5, 10, 100, 1000];
for (let numberElement of numberElements) {
const timings = [];
const array = [];
// Variable number of elements
for (let i = 0; i < numberElement; i++) {
array.push(i);
}
// 100_000 iterations
for (let i = 0; i < 100000; i++) {
const endTimer = startTimer();
// Tests performances of Array#map
array.map((v) => v);
timings.push(endTimer());
}
// We take the average of all iterations
console.log(
numberElement,
' ',
timings.reduce((a, b) => a + b) / timings.length,
);
}
vs
const numberElements = [1, 2, 5, 10, 100, 1000];
for (let numberElement of numberElements) {
const timings = [];
const array = [];
// Variable number of elements
for (let i = 0; i < numberElement; i++) {
array.push(i);
}
// 100_000 iterations
for (let i = 0; i < 100000; i++) {
const endTimer = startTimer();
// Tests performances of foreact loop + push
// Named custom in the table below
const arrayToFill = [];
for (let j = 0; j < array.length; j++) {
arrayToFill.push(array[j]);
}
timings.push(endTimer());
}
// We take the average of all iterations
console.log(
numberElement,
' ',
timings.reduce((a, b) => a + b) / timings.length,
);
}
The performance results after 100_000 iterations are (time in milliseconds):
Number of elements | Custom | Array#map |
---|---|---|
1 | 0.0001424 | 0.0001189 |
2 | 0.0001098 | 0.00009673 |
5 | 0.0001061 | 0.00007735 |
10 | 0.0001502 | 0.0001135 |
100 | 0.0005938 | 0.0002440 |
1000 | 0.004421 | 0.001771 |
We can see that Array#map is faster than the custom implementation with foreach loop. So does the PR see above about performances is a fraud?
Actually no, we have to go back in the past. The PR has been made in 2016, at this moment it wasn’t the same version of **V8 Javascript Engine*,
the **nodejs** version was v6.x.x
.
Let’s remake with the version 6.17.1
of nodejs:
Number of elements | Custom | Array#map |
---|---|---|
1 | 0.0001575 | 0.0005169 |
2 | 0.0001552 | 0.0007389 |
5 | 0.0002615 | 0.001449 |
10 | 0.0003702 | 0.002593 |
100 | 0.002849 | 0.02402 |
1000 | 0.02965 | 0.2824 |
Indeed gains are huge: from 5 to 10 times faster with the custom implementation. Performances have been improved from nodejs v10.x.x
, from that
version the custom implementation becomes slower than Array#map
.
Spread operator
vsapply
function fakeMethod() {}
const numberElements = [1, 2, 5, 10, 100, 1000];
for (let numberElement of numberElements) {
const timings = [];
const array = [];
// Variable number of elements
for (let i = 0; i < numberElement; i++) {
array.push(i);
}
// 100_000 iterations
for (let i = 0; i < 100000; i++) {
const endTimer = startTimer();
// We simulate 5 calls to the function by spreading the array
fakeMethod(...array);
fakeMethod(...array);
fakeMethod(...array);
fakeMethod(...array);
fakeMethod(...array);
timings.push(endTimer());
}
// We take the average of all iterations
console.log(
numberElement,
' ',
timings.reduce((a, b) => a + b) / timings.length,
);
}
vs
function fakeMethod() {}
const numberElements = [1, 2, 5, 10, 100, 1000];
for (let numberElement of numberElements) {
const timings = [];
const array = [];
// Variable number of elements
for (let i = 0; i < numberElement; i++) {
array.push(i);
}
// 100_000 iterations
for (let i = 0; i < 100000; i++) {
const endTimer = startTimer();
// We simulate 5 executions of the method by passing the array to the apply function
fakeMethod.apply(null, array);
fakeMethod.apply(null, array);
fakeMethod.apply(null, array);
fakeMethod.apply(null, array);
fakeMethod.apply(null, array);
timings.push(endTimer());
}
// We take the average of all iterations
console.log(
numberElement,
' ',
timings.reduce((a, b) => a + b) / timings.length,
);
}
And I get the following performances (in milliseconds):
Number of parameters | Spread operator | apply |
---|---|---|
1 | 0.0001765 | 0.0001943 |
2 | 0.0001515 | 0.0001637 |
5 | 0.0001398 | 0.0001403 |
10 | 0.0001749 | 0.0001876 |
100 | 0.0005981 | 0.0006265 |
1000 | 0.004542 | 0.004691 |
We can see that nowadays with the version of node v15.5.0
, the performances with Spread operators are quite better.
Let’s test with the version 6.17.1
of nodejs:
Number of parameters | Spread operator | apply |
---|---|---|
1 | 0.001346 | 0.0001981 |
2 | 0.001566 | 0.0001617 |
5 | 0.002525 | 0.0002052 |
10 | 0.003823 | 0.0002038 |
100 | 0.02822 | 0.0004328 |
1000 | 0.3517 | 0.002866 |
The optimization is real at the time, almost 10 to 100 times faster! The performances switch from the version 8.x.x
of nodejs.
We have seen in the previous part that when the PR to improve performances has been created, performances gains was really huged. But nowadays, with the otpimizations made to V8 Javascript Engine, the optimization from the PR are not valid anymore. So we could have the following implementation:
export function createSelectorCreator(
memoize,
...memoizeOptions
) {
return (...funcs) => {
const resultFunc = funcs.pop();
const dependenciesFuncs = getDependencies(funcs);
let recomputations = 0;
const memoizeResultFunc = memoize(
function () {
recomputations++;
return resultFunc(...arguments);
},
...memoizeOptions,
);
const selector = memoize(function () {
const params = dependenciesFuncs.map((dependency) =>
dependency(...arguments),
);
return memoizeResultFunc(...parameters);
});
selector.resultFunc = resultFunc;
selector.dependencies = dependenciesFuncs;
selector.recomputations = () => recomputations;
selector.resetRecomputations = () =>
(recomputations = 0);
return selector;
};
}
export const createSelector =
createSelectorCreator(defaultMemoize);
You can find me on Twitter if you want to comment this post or just contact me. Feel free to buy me a coffee if you like the content and encourage me.