Reselect: Return previous state if the props change but not the output

Created on 28 Jan 2020  路  11Comments  路  Source: reduxjs/reselect

I have an array of posts, which is filtered by user such that I have the following

const currentUsersPosts = createSelector(
  [state => state.currentUser.id, state=> state.posts],
 (userId, posts) => posts.filter(p=>p.id === userId)
);

When a new post is creates for another user this correctly filters the list and produces an identical (but differently referenced) array. This means that I re-render the component needlessly. Is there a way to get at the previously memo'd value for this instance of the reselector and return that if it's shallow equal (hence avoiding children re-rendering)?

eg:

import { shallowEqualArrays } from 'shallow-equal';

const currentUsersPosts = createSelector(
  [state => state.currentUser.id, state=> state.posts],
 (userId, posts, prevPostsForUser) => {
     const nextPostsForUser = posts.filter(p=>p.id === userId);
     return shallowEqualArrays(prevPostsForUser, nextPostsForUser) 
        ? prevPostsForUser
         : nextPostsForUser;
  }
);

Most helpful comment

Oh hey, those are pretty neat!

Seems like a common enough problem that it might even be worth at least including this as a documented recipe, if not an actual API addition.

All 11 comments

I believe you can do this with createSelectorCreator.

I believe this would accomplish what OP wants, albeit in a more verbose way. It might be helpful to provide an API surface like the one OP was asking for as it would allow the consumer to more easily choose when to cache the result or not.

import { createSelectorCreator } from 'reselect';
import { shallowEqualArrays } from 'shallow-equal';

// START STUFF TAKEN DIRECTLY FROM RESELECT SOURCE
function defaultEqualityCheck(a, b) {
  return a === b;
}

function areArgumentsShallowlyEqual(equalityCheck, prev, next) {
  if (prev === null || next === null || prev.length !== next.length) {
    return false;
  }

  // Do this in a for loop (and not a `forEach` or an `every`) so we can determine equality as fast as possible.
  const length = prev.length;
  for (let i = 0; i < length; i++) {
    if (!equalityCheck(prev[i], next[i])) {
      return false;
    }
  }

  return true;
}
// END STUFF TAKEN DIRECTLY FROM RESELECT SOURCE

function customMemoize(func, resultEqualityCheck = defaultEqualityCheck, equalityCheck = defaultEqualityCheck) {
  let lastArgs = null;
  let lastResult = null;
  // we reference arguments instead of spreading them for performance reasons
  return function () {
    if (!areArgumentsShallowlyEqual(equalityCheck, lastArgs, arguments)) {
      // apply arguments instead of spreading for performance.
      // lastResult = func.apply(null, arguments)

      // BEGIN CUSTOMIZATION OF MEMOIZE FUNCTION TO ALSO MEMOIZE RESULT
      const result = func.apply(null, arguments);
      if (!resultEqualityCheck(lastResult, result)) {
        lastResult = result;
      }
      // END CUSTOMIZATION OF MEMOIZE FUNCTION TO ALSO MEMOIZE RESULT

    }

    lastArgs = arguments;
    return lastResult;
  }
}

const createArrayResultSelector = createSelectorCreator(customMemoize, shallowEqualArrays);

const currentUsersPosts = createArrayResultSelector(
  [state => state.currentUser.id, state=> state.posts],
 (userId, posts) => posts.filter(p=>p.id === userId)
);

FWIW heres how I solved it myself

import { createSelectorCreator } from 'reselect';
import { shallowEqualArrays, shallowEqualObjects } from 'shallow-equal';

const resultMemoize = (func) => {
  let lastArgs = null;
  let lastResult = null;

  return (...args) => {
    if (!shallowEqualArrays(lastArgs, args)) {
      // faster with apply, this might get called a lot
      // https://medium.com/@pouyae/what-is-the-es6-spread-operator-and-why-you-shouldnt-use-it-57c056078ed9
      // eslint-disable-next-line prefer-spread
      const nextResult = func.apply(null, args);
      if (Array.isArray(nextResult) && shallowEqualArrays(nextResult, lastResult)) {
        // keep the last result
      } else if (typeof nextResult === 'object' && shallowEqualObjects(nextResult, lastResult)) {
        // keep the last result
      } else {
        lastResult = nextResult;
      }
    }

    lastArgs = args;
    return lastResult;
  };
};

export default createSelectorCreator(resultMemoize);

usage:

export const selector = createResultCheckingSelector(
  [
    state => state.complexThings
  ],
  complexThings => complexThings.reduce((acc, thingKey) => {
      acc[thingKey] =state.complexThings[thingKey].simpleThing;
      return acc;
    }, {})
);

If other unrelated parts of my state are mutated it wont trigger everything else to re-render. The bit in here thats gross is that I have to keep my own copy of args and lastResult (which reselect also keeps but doesn't expose).

You _can_ manually expose the last result of the selector yourself.

import { createSelector } from 'reselect';
import { shallowEqualArrays } from 'shallow-equal';

const currentUsersPosts = createSelector(
  [state => state.currentUser.id, state=> state.posts],
  (userId, posts) => {
    const lastResult = currentUsersPosts.lastResult;
    const result = posts.filter(p=>p.id === userId);
    if (shallowEqualArrays(lastResult, result)) return lastResult;
    currentUsersPosts.lastResult = result;
    return result;
  }
);

interesting, ill give that a go, it will clean things up quite a bit.

Not sure why nobody uses defaultMemoize that's already in reselect to do this:

import { defaultMemoize, createSelector } from 'reselect';

const createMemoizeArray = () => {
  const memArray = defaultMemoize((...array) => array);
  return (array) => memArray.apply(null, array);
};
const selectCurrentUsersPosts = ((memoizeArry) =>
  createSelector(
    [
      (state) => state.currentUser.id,
      (state) => state.posts,
    ],
    (userId, posts) =>
      memoizeArry(posts.filter((p) => p.id === userId))
  ))(createMemoizeArray());//IIFE

When you want to pass user id to the selector because you you want to show list of users and posts of each user you can do the following:

const createMemoizeArray = () => {
  const memArray = defaultMemoize((...array) => array);
  return (array) => memArray.apply(null, array);
};
const createSelectCurrentUsersPosts = (userId) => {//curry user id
  const memoizeArry = createMemoizeArray();//create its own memoize array
  return createSelector([(state) => state.posts], (posts) =>
    memoizeArry(posts.filter((p) => p.id === userId))
  );
};
// used in components
const UserList = (users) => (
  <ul>
    {users.map((user) => (
      <UserContainer user={user} key={user.id} />
    ))}
  </ul>
);
const UserContainer = ({ user }) => {
  const selectUserPosts = React.useMemo(//(re) create selector based on user id
    createSelectCurrentUsersPosts(user.id),
    [user.id]
  );
  const userPosts = useSelector(selectUserPosts);
  return 'jsx';
};

@amsterdamharu just wanted to extend your brilliant answer with a createMemoizeArrayFn so you don't need a selector factory:

const memoizeArrayProducingFn = (fn) => {
  const memArray = defaultMemoize((...array) => array);
  return (...args) => memArray(...fn(...args));
};


const selectCurrentUsersPosts createSelector(
    [
      (state) => state.currentUser.id,
      (state) => state.posts,
    ],
    memoizeArrayProducingFn((userId, posts) => posts.filter((p) => p.id === userId))
);

@akmjenkins thanks for the nice succinct function above. For those using typescript, I found that the function above destroys the types associated with the output arguments of fn - so I modified it as something like this:

    function memoizeArrayProducingFn<S, T>(
      fn: (input: S[]) => T[]
    ): (input: S[]) => T[] {
      const memArray = defaultMemoize((...array) => array);
      return (...args) => memArray(...fn(...args));
    }

Thanks @akmjenkins for the short and sweet solution and thanks @Mchristos for the typed version.
Here is another modified version similar to @Mchristos which takes an arbitrary number of arguments, and should preserve types:

function memoizeArrayProducingFnTyped<T extends (...args) => any[]>(fn: T) {
  const memArray = defaultMemoize((...array) => array);
  return ((...args) => memArray(...fn(...args))) as T;
}

Oh hey, those are pretty neat!

Seems like a common enough problem that it might even be worth at least including this as a documented recipe, if not an actual API addition.

I've been using a similar idea which I've called "stabilize". (A "stable" identity is one that doesn't needlessly change.)

stabilize is a higher order function which ensures the provided function will return the same identity if the result is determined to be equal by a given equality function.

These helpers can be used to memoize not just selectors which return arrays, but selectors which return any non-primitive type. Furthermore, this idea can be used as a React hook called useStable, alongside useMemo (which is conceptually similar to a selector / createSelector, that is it only returns the combiner function if the arguments change).

import { shallowEqualArrays } from 'shallow-equal';

//
// Example usage in Reselect
//

import assert from 'assert';
import { createSelector, defaultMemoize } from 'reselect';

const stabilize = <A extends unknown[], R>(
    fn: (...args: A) => R,
    isResultEqual: (a: R, b: R) => boolean,
): ((...args: A) => R) => {
    const memoizedIdentity = defaultMemoize(
        (result: R) => result,
        // `defaultMemoize` has bad typings.
        // @ts-ignore
        isResultEqual,
    );
    return (...args) => memoizedIdentity(fn(...args));
};

/** Re-use the previous result if the Array value matches the latest result */
const stabilizeArray = <A extends unknown[], R>(
    fn: (...args: A) => Array<R>,
): ((...args: A) => Array<R>) => stabilize(fn, shallowEqualArrays);

type User = {
    id: string;
    age?: number;
};

type State = {
    users: { [key: string]: User };
};

const state1: State = {
    users: {
        foo: { id: 'foo' },
        bar: { id: 'bar' },
    },
};
const state2: State = {
    users: {
        foo: { id: 'foo', age: 1 },
        bar: { id: 'bar' },
    },
};

const getUsersById = (state: State) => state.users;

const getUserIds = stabilizeArray(
    createSelector(getUsersById, (users) => Object.keys(users)),
);
// OR
// const getUserIds = createSelector(
//     getUsersById,
//     stabilizeArray((users) => Object.keys(users)),
// );

assert(getUserIds(state1) === getUserIds(state2));

//
// Example usage in React
//

import React, { FC, useMemo, useRef } from 'react';

/**
 * This is a hook version of `stabilize`.
 * Only store/return a new value when it's determined to be inequal to the previous value.
 */
const useStable = <T,>(value: T, equalityCheck: (a: T, b: T) => boolean): T => {
    const ref = useRef(value);

    if (equalityCheck(ref.current, value) === false) {
        ref.current = value;

        return value;
    } else {
        return ref.current;
    }
};

// `userIds` has same reference identity on second render
const MyComponent: FC<{ users: State['users'] }> = ({ users }) => {
    const _userIds = useMemo(() => Object.keys(users), [users]);
    const userIds = useStable(_userIds, shallowEqualArrays);
    console.log('render', userIds);
    return null;
};

<MyComponent users={state1.users} />;
<MyComponent users={state2.users} />;
Was this page helpful?
0 / 5 - 0 ratings