Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Return previous state if the props change but not the output #441

Closed
lukemcgregor opened this issue Jan 28, 2020 · 12 comments
Closed

Return previous state if the props change but not the output #441

lukemcgregor opened this issue Jan 28, 2020 · 12 comments

Comments

@lukemcgregor
Copy link

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;
  }
);
@lukemcgregor lukemcgregor changed the title Return previous memo if the props change but not the output Return previous state if the props change but not the output Jan 28, 2020
@danielcruser
Copy link

I believe you can do this with createSelectorCreator.

@jcready
Copy link

jcready commented Mar 24, 2020

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)
);

@lukemcgregor
Copy link
Author

lukemcgregor commented Mar 24, 2020

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).

@jcready
Copy link

jcready commented Mar 25, 2020

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;
  }
);

@lukemcgregor
Copy link
Author

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

@amsterdamharu
Copy link

amsterdamharu commented Jun 1, 2020

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';
};

@akmjenkins
Copy link

@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))
);

@Mchristos
Copy link

@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));
    }

@timminata
Copy link

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;
}

@markerikson
Copy link
Contributor

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.

@OliverJAsh
Copy link

OliverJAsh commented Dec 13, 2020

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} />;

@markerikson
Copy link
Contributor

Resolved by #513 .

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

9 participants