🤖🚫 AI-free content. This post is 100% written by a human, as is everything on my blog. Enjoy!

Reselect memoization explained

June 13, 2021 in JavaScript

Reselect is an essential library to build functional business logic on top of your Redux state, and I use it extensively.

One of the core Reselect features is memoization of results, so your combiner functions are not invoked “when they don’t need to”. Unfortunately, there are nuances that might diminish this benefit, unless you understand the Reselect logic.

Most importantly, you should be wary that the equalityCheck of defaultMemoize only affects how results of input selectors are compared. To know more, read on.

Reselect memoization is based on referential transparency

Combiners and input selectors are (must be) referentially transparent. If you call them twice with the same arguments, you get the same result. This creates the basis of reselect optimization: if you memoize the result of the referentially transparent function, there is no need to call the function again with the same arguments.

Reselect does this on two levels:

First, the entire selector function is memoized based on its arguments. So, if you call the selector twice on the same state, both the input selectors and the combiner will only be called once.

const { createSelector } = require("reselect");

const memoizedSelector = createSelector(
  (state) => {
    console.log("Input selector called");
    return state.foo;
  },
  (foo) => {
    console.log("Combiner called");
    return foo.bar;
  }
);

const state = { foo: { bar: "baz" } };
console.log(memoizedSelector(state));
// Input selector called
// Combiner called
// baz
console.log(memoizedSelector(state));
// baz

What do we learn from this? That you can call the same selector as many times as you need, without penalty. (OK, a negligible penalty for comparing arguments.)

If it’s used in 100 components – it would still be called once. And even more importantly, the input selectors and their dependencies are also called once. So, there is no penalty for having a deep dependency tree. (In case you didn’t know, you can and should build selectors from other selectors, but that’s a topic for another article.)

This lets us express complex business logic as a tree of simple, composable, reusable selectors.

Second, the combiner function is memoized based on return values of input selectors. So, if you call the selector twice on different states, but the input selectors return the same values, the combiner is only called once.

const subState = { bar: "baz" };
console.log(memoizedSelector({ foo: subState }));
// Input selector called
// Combiner called
// baz
console.log(memoizedSelector({ foo: subState }));
// Input selector called
// baz

What do we learn from this? That if you have an expensive function, you can avoid recomputing it too often by narrowing its inputs with input selectors. And, of course, it’s also the way how our business logic tree is only updated “when it needs to”.

Keeping results referentially stable

There are plenty of cases where the combiner value remains the same when input values change.

(This has nothing to do with referential transparency, it’s just a quality that the combiner might have.)

const simpleSearchSelector = createSelector(
  (state) => state.items,
  (state) => state.query,
  (items, query) => items.filter((item) => item.includes(query))
);

const items = ["foo", "bar", "baz"];

const result1 = simpleSearchSelector({
  items,
  query: "b",
});

const result2 = simpleSearchSelector({
  items,
  query: "ba",
});
console.log(result1, result2);
// [ 'bar', 'baz' ] [ 'bar', 'baz' ]

So far, so good. Well, what if you use this selector in your component or other selectors, and they check if the value has changed?

console.log(result1 === result2);
// false

Uh oh. Of course, a new array or object will be created in every call to the combiner, so you cannot stop dependent code from registering a change.

(You can even argue that JavaScript functions that return complex values are never truly referentially stable.)

So, to mitigate this, we have customizable equality checks – useSelector has one. And createSelector seemingly also allows customizing the equality check.

const { isEqual } = require("lodash");
const { createSelectorCreator, defaultMemoize } = require("reselect");

const createSelectorWithIsEqual = createSelectorCreator(
  defaultMemoize,
  isEqual
);

// Same code as in previous example,
// but using createSelectorWithIsEqual instead of createSelector

const smarterSearchSelector = createSelectorWithIsEqual(
  (state) => state.items,
  (state) => state.query,
  (items, query) => items.filter((item) => item.includes(query))
);

const smartResult1 = smarterSearchSelector({
  items,
  query: "b",
});

const smartResult2 = smarterSearchSelector({
  items,
  query: "ba",
});

console.log(smartResult1, smartResult2, smartResult1 === smartResult2);
// [ 'bar', 'baz' ] [ 'bar', 'baz' ] false

Still no good. That’s because the equality check passed to createSelectorCreator only applies to input parameters. To avoid recomputing the combiner for the same values of input selectors. As to the return value of the combiner – Reselect does not care at all. It simply returns whatever the combiner returned last.

Of course, you can handle this on the caller’s side:

const smartResult1 = useSelector(smarterSearchSelector, isEqual);

But I don’t like this approach. First, it applies to every instance of the component separately; every useSelector hook will individually check the selector response for equality. Secondly, this equality check knows nothing of the memoization happening inside the selector, and will run even if the selector is returning a memoized result. (In the case of Lodash’s isEqual, this is negligible because it will check if objects are equal by reference)

But most importantly, I think that the decision to stabilize the return value belongs to the selector. It is there that you can evaluate if an equality check is worth doing.

So with this, I bring you a simple function that will make any other function referentially stable (same value – same reference), at the cost of an equality check on every call.

function stabilize(combiner) {
  let result;
  return (...args) => {
    const newResult = combiner(...args);
    if (!isEqual(result, newResult)) {
      result = newResult;
    }
    return result;
  };
}

Because the equality check happens on every call to this function, it’s better to stabilize the combiner, and not the entire selector function, to make use of its memoization:

const { slice, last, isEqual, concat } = require("lodash");
const { createSelector } = require("reselect");

const createStableSelector = (...args) => {
  const inputSelectors = slice(args, 0, -1);
  const combiner = last(args);
  return createSelector(...concat(inputSelectors, stabilize(combiner)));
};

And finally, we get our referentially stable selector results:

const stableSearchSelector = createStableSelector(
  (state) => state.items,
  (state) => state.query,
  (items, query) => items.filter((item) => item.includes(query))
);

const stableResult1 = stableSearchSelector({
  items,
  query: "b",
});

const stableResult2 = stableSearchSelector({
  items,
  query: "ba",
});

console.log(stableResult1, stableResult2, stableResult1 === stableResult2);
// [ 'bar', 'baz' ] [ 'bar', 'baz' ] true

Ah, functional paradise! Just remember that you pay the price – an equality check – and it is definitely not worth making every selector stable. There are two common cases where it is.

First case: filtering a list based on some variable criteria (as in my search example.) For this case, you can even replace isEqual with shallow-equal.

Second case: if a change in the selector triggers expensive operations (think, calling backend APIs.) (Do you know you can use selectors with store.subscribe()?)

In case you want to understand Reselect even better, I recommend reading this source code, which is shorter than this article. And, experiment on small examples – you don’t need to test everything on your full application state.

Buy me a coffee Liked the post? Treat me to a coffee