I have a selector:
const someSelector = createSelector( getUserIdsSelector, (ids) => ids.map((id) => yetAnotherSelector(store, id), ); // ^^^^^ (yetAnotherSelector expects 2 args)
That yetAnotherSelector
is another selector, that takes user id - id
and returns some data.
However, since it's createSelector
, I don't have access to store in it (I don't want it as a function because the memoization wouldn't work then).
Is there a way to access store somehow inside createSelector
? Or is there any other way to deal with it?
I have a function:
const someFunc = (store, id) => { const data = userSelector(store, id); // ^^^^^^^^^^^^ global selector return data.map((user) => extendUserDataSelector(store, user)); // ^^^^^^^^^^^^^^^^^^^^ selector }
Such function is killing my app, causing everything to re-render and driving me nuts. Help appreciated.
I have done some basic, custom memoization:
import { isEqual } from 'lodash'; const memoizer = {}; const someFunc = (store, id) => { const data = userSelector(store, id); if (id in memoizer && isEqual(data, memoizer(id)) { return memoizer[id]; } memoizer[id] = data; return memoizer[id].map((user) => extendUserDataSelector(store, user)); }
And it does the trick, but isn't it just a workaround?
createSelector Overview Reselect provides a function called createSelector to generate memoized selectors. createSelector accepts one or more "input selector" functions, plus an "output selector" function, and returns a new selector function for you to use.
Reselect is useful because you can compose selectors and the memoization can prevent expensive selector code from running. The memoization can also prevent needless re renders. What you should consider is maintainable code and with reselect you can write selector logic once and re use it by composing selectors.
A library for creating memoized "selector" functions. Commonly used with Redux, but usable with any plain JS immutable data as well. Selectors can compute derived data, allowing Redux to store the minimal possible state.
For your specific case, I would create a selector that itself returns an extender.
That is, for this:
const someFunc = (store, id) => { const data = userSelector(store, id); // ^^^^^^^^^^^^ global selector return data.map((user) => extendUserDataSelector(store, user)); // ^^^^^^^^^^^^^^^^^^^^ selector }
I would write:
const extendUserDataSelectorSelector = createSelector( selectStuffThatExtendUserDataSelectorNeeds, (state) => state.something.else.it.needs, (stuff, somethingElse) => // This function will be cached as long as // the results of the above two selectors // does not change, same as with any other cached value. (user) => { // your magic goes here. return { // ... user with stuff and somethingElse }; } );
Then someFunc
would become:
const someFunc = createSelector( userSelector, extendUserDataSelectorSelector, // I prefix injected functions with a $. // It's not really necessary. (data, $extendUserDataSelector) => data.map($extendUserDataSelector) );
I call it the reifier pattern because it creates a function that is pre-bound to the current state and which accepts a single input and reifies it. I usually used it with getting things by id, hence the use of "reify". I also like saying "reify", which is honestly the main reason I call it that.
In this case:
import { isEqual } from 'lodash'; const memoizer = {}; const someFunc = (store, id) => { const data = userSelector(store, id); if (id in memoizer && isEqual(data, memoizer(id)) { return memoizer[id]; } memoizer[id] = data; return memoizer[id].map((user) => extendUserDataSelector(store, user)); }
That's basically what re-reselect does. You may wish to consider that if you plan on implementing per-id memoization at the global level.
import createCachedSelector from 're-reselect'; const someFunc = createCachedSelector( userSelector, extendUserDataSelectorSelector, (data, $extendUserDataSelector) => data.map($extendUserDataSelector) // NOTE THIS PART DOWN HERE! // This is how re-reselect gets the cache key. )((state, id) => id);
Or you can just wrap up your memoized-multi-selector-creator with a bow and call it createCachedSelector
, since it's basically the same thing.
Another way you can do this is to just select all the appropriate data needed to run the extendUserDataSelector
calculation, but this means exposing every other function that wants to use that calculation to its interface. By returning a function that accepts just a single user
base-datum, you can keep the other selectors' interfaces clean.
One thing the above implementation is currently vulnerable to is if extendUserDataSelectorSelector
's output changes because its own dependency-selectors change, but the user data gotten by userSelector
did not change, and neither did actual computed entities created by extendUserDataSelectorSelector
. In those cases, you'll need to do two things:
extendUserDataSelectorSelector
returns. I recommend extracting it to a separate globally-memoized function.someFunc
so that when it returns an array, it compares that array element-wise to the previous result, and if they have the same elements, returns the previous result.Caching at the global level is certainly doable, as shown above, but you can avoid that if you approach the problem with a couple other strategies in mind:
I didn't follow those at first in one of my major work projects, and wish I had. As it is, I had to instead go the global-memoization route later since that was easier to fix than refactoring all the views, something which should be done but which we currently lack time/budget for.
NOTE: Before you go through this part, it presumes that the Base Entity being passed to the Extender will have some sort of id
property that can be used to identify it uniquely, or that some sort of similar property can be derived from it cheaply.
For this, you memoize the Extender itself, in a manner similar to any other Selector. However, since you want the Extender to memoize on its arguments, you don't want to pass State directly to it.
Basically, you need a Multi-Memoizer that basically acts in the same manner as re-reselect does for Selectors. In fact, it's trivial to punch createCachedSelector
into doing that for us:
function cachedMultiMemoizeN(n, cacheKeyFn, fn) { return createCachedSelector( // NOTE: same as [...new Array(n)].map((e, i) => Lodash.nthArg(i)) [...new Array(n)].map((e, i) => (...args) => args[i]), fn )(cacheKeyFn); } function cachedMultiMemoize(cacheKeyFn, fn) { return cachedMultiMemoizeN(fn.length, cacheKeyFn, fn); }
Then instead of the old extendUserDataSelectorSelector
:
const extendUserDataSelectorSelector = createSelector( selectStuffThatExtendUserDataSelectorNeeds, (state) => state.something.else.it.needs, (stuff, somethingElse) => // This function will be cached as long as // the results of the above two selectors // does not change, same as with any other cached value. (user) => { // your magic goes here. return { // ... user with stuff and somethingElse }; } );
We have these two functions:
// This is the main caching workhorse, // creating a memoizer per `user.id` const extendUserData = cachedMultiMemoize( // Or however else you get globally unique user id. (user) => user.id, function $extendUserData(user, stuff, somethingElse) { // your magic goes here. return { // ...user with stuff and somethingElse }; } ); // This is still wrapped in createSelector mostly as a convenience. // It doesn't actually help much with caching. const extendUserDataSelectorSelector = createSelector( selectStuffThatExtendUserDataSelectorNeeds, (state) => state.something.else.it.needs, (stuff, somethingElse) => // This function will be cached as long as // the results of the above two selectors // does not change, same as with any other cached value. (user) => extendUserData( user, stuff, somethingElse ) );
That extendUserData
is where the real caching occurs, though fair warning: if you have a lot of baseUser
entities, it could grow pretty large.
Arrays are the bane of caching existence:
arrayOfSomeIds
may itself not change, but the entities that the ids within point to could have.arrayOfSomeIds
might be a new object in memory, but in reality has the same ids.arrayOfSomeIds
did not change, but the collection holding the referred-to entities did change, yet the particular entities referred to by these specific ids did not change.That all is why I advocate for delegating the extension/expansion/reification/whateverelseification of arrays (and other collections!) to as late in the data-getting-deriving-view-rendering process as possible: It's a pain in the amygdala to have to consider all of this.
That said, it's not impossible, it just incurs some extra checking.
Starting with the above cached version of someFunc
:
const someFunc = createCachedSelector( userSelector, extendUserDataSelectorSelector, (data, $extendUserDataSelector) => data.map($extendUserDataSelector) // NOTE THIS PART DOWN HERE! // This is how re-reselect gets the cache key. )((state, id) => id);
We can then wrap it in another function that just caches the output:
function keepLastIfEqualBy(isEqual) { return function $keepLastIfEqualBy(fn) { let lastValue; return function $$keepLastIfEqualBy(...args) { const nextValue = fn(...args); if (! isEqual(lastValue, nextValue)) { lastValue = nextValue; } return lastValue; }; }; } function isShallowArrayEqual(a, b) { if (a === b) return true; if (Array.isArray(a) && Array.isArray(b)) { if (a.length !== b.length) return false; // NOTE: calling .every on an empty array always returns true. return a.every((e, i) => e === b[i]); } return false; }
Now, we can't just apply this to the result of createCachedSelector
, that'd only apply to just one set of outputs. Rather, we need to use it for each underlying selector that createCachedSelector
creates. Fortunately, re-reselect lets you configure the selector creator it uses:
const someFunc = createCachedSelector( userSelector, extendUserDataSelectorSelector, (data, $extendUserDataSelector) => data.map($extendUserDataSelector) )((state, id) => id, // NOTE: Second arg to re-reselect: options object. { // Wrap each selector that createCachedSelector itself creates. selectorCreator: (...args) => keepLastIfEqualBy(isShallowArrayEqual)(createSelector(...args)), } )
You may have noticed that we only check array outputs, covering cases 1 and 3, which may be good enough. Sometimes, however, you may need catch case 2, as well, checking the input array. This is doable by using reselect's createSelectorCreator
to make our own createSelector
using a custom equality function
import { createSelectorCreator, defaultMemoize } from 'reselect'; const createShallowArrayKeepingSelector = createSelectorCreator( defaultMemoize, isShallowArrayEqual ); // Also wrapping with keepLastIfEqualBy() for good measure. const createShallowArrayAwareSelector = (...args) => keepLastIfEqualBy( isShallowArrayEqual )( createShallowArrayKeepingSelector(...args) ); // Or, if you have lodash available, import compose from 'lodash/fp/compose'; const createShallowArrayAwareSelector = compose( keepLastIfEqualBy(isShallowArrayEqual), createSelectorCreator(defaultMemoize, isShallowArrayEqual) );
This further changes the someFunc
definition, though just by changing the selectorCreator
:
const someFunc = createCachedSelector( userSelector, extendUserDataSelectorSelector, (data, $extendUserDataSelector) => data.map($extendUserDataSelector) )((state, id) => id, { selectorCreator: createShallowArrayAwareSelector, });
That all said, you should try taking a look at what shows up in npm when you search for reselect
and re-reselect
. Some new tools there that may or may not be useful to certain cases. You can do a lot with just reselect and re-reselect plus a few extra functions to fit your needs, though.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With