Skip to content

kortsi/redux-views

 
 

Repository files navigation

redux-views

Redux-Views aims to solve the problem with shared selectors in a way that makes them easier to compose and to work with.

It solves the same problem that re-reselect tries to solve, but using a very different approach. Instead of "enhancing" reselect, redux-views is an all-in-one solution that tries to make the experience of working with shared selectors (almost) transparent for the user.

The API of Redux-Views is almost identical to the API of reselect. Basically, it keeps reselect's 2 high-level functions:

  • createSelector has the exact same signature.
  • createStructureSelector is identical to the one provided by reselect except that it only accepts the first argument.

On top of those 2 functions, Redux-Views adds a new function: createIdSelector, which helps the library to identify those selectors that just return ids.

Redux-Views also provides handy means for dealing with cache-invalidation. Its approach differs quite substantially from the one taken by re-reselect. Redux-Views prefferred approach consists of keeping an internal ref-count of the usages of a cache, in order to automatically clean it when those values are no longer needed.

The problem with reselect

const getUsers = state => state.users
const getPropId = (state, {id}) => id

const getUser = createSelector(
  [getUsers, getPropId],
  (users, id) => users[id]
)

getUser(state, {id: '1'})
getUser(state, {id: '2'})

getUser(state, {id: '1'})
getUser(state, {id: '2'})

getUser.recomputations() // => 4

Redux-Views solution

const getUsers = state => state.users
const getPropId = createIdSelector(({id}) => id)

const getUser = createSelector(
  [getUsers, getPropId],
  (users, id) => users[id]
)

getUser(state, {id: '1'})
getUser(state, {id: '2'})

getUser(state, {id: '1'})
getUser(state, {id: '2'})
getUser.recomputations() // => 2

The only difference is that the second snippet uses createIdSelector in the declaration of getPropId.

createIdSelector takes a function that:

  • Receives all the parameters that are passed to the resulting selector except for the state
  • Must return the ID of the instance that consumes the resulting selector. And it returns a normal selector.

Using this enhancer for generating id-selectors allows Redux-Views to understand the usages of the selectors that depend on them. In other words, using createIdSelector for creating selectors that return ids allows Redux-Views to optimally memoize the other selectors.

For instance, consider this example:

const getUsers = state => state.users
const getLoadingUsers = state => state.loadingUsers
const getPropId = createIdSelector(({id}) => id)

const getUser = createSelector(
  [getUsers, getPropId],
  (users, id) => users[id]
)

const getIsUserLoading = createSelector(
  [getLoadingUsers, getPropId],
  (loadingUsers, id) => Boolean(loadingUsers[id])
)

const getUserInfo = createStructuredSelector({
  user: getUser,
  isLoading: getIsUserLoading,
})

Both getUsers and getIsUserLoading depend on the same "id-selector": getPropId. Redux-Views has a way to know that the results of those selectors should be cached taking that id into account. On the other hand, getuserInfo depends on 2 different selectors that share the same "id-selector". Therefore, getUserInfo will also use that same id-selector in order to cache its results. Let's see it:

getUserInfo(state, {id: '1'})
getUserInfo(state, {id: '2'})

getUserInfo(state, {id: '1'})
getUserInfo(state, {id: '2'})

getUserInfo.recomputations() // => 2
getIsUserLoading.recomputations() // => 2
getUser.recomputations() // => 2

The state has not changed and we have queried the same state using 2 different IDs. That is why all the selectors have been computed only twice. Now let's see what happens when we change the state in a way that does not affect the data of user "1" or user "2":

const newState = {
  ...state,
  loadingUsers: { ...state.loadingUsers, '3': true }
}

getUserInfo(newState, {id: '1'})
getUserInfo(newState, {id: '2'})

getUserInfo.recomputations() // => 2
getIsUserLoading.recomputations() // => 2
getUser.recomputations() // => 2

Again, the same number of computations, because nothing relevant has changed, awesome!

Now let's try making a relevant change, the loading status of user "2":

const newState = {
  ...state,
  loadingUsers: { ...state.loadingUsers, '2': true }
}

getUserInfo(newState, {id: '1'})
getUserInfo(newState, {id: '2'})

getUserInfo.recomputations() // => 3
getIsUserLoading.recomputations() // => 3
getUser.recomputations() // => 2

Notice how getUserInfo and getIsUserLoading have increased the number of recomputations, cool! However, the recomputations of getUser remain the same, right? That's because the part of the state that's relevant to getUser has not changed.

I know what you must be thinking: what happens when a selector depends on more than one different id-selector?

I'm glad that you asked. Let's try it:

const getUsers = state => state.users

const getPropIdA = createIdSelector(({idA}) => idA)
const getPropIdB = createIdSelector(({idB}) => idB)

const userById = (users, id) => users[id]
const getUserA = createSelector([getUsers, getPropIdA], userById)
const getUserB = createSelector([getUsers, getPropIdB], userById)

const getJoinedUsers = createSelector(
  [getUserA, getUserB],
  (userA, userB) => {
    // => Some expensive operation in order to join 2 users
  }
)

In this example, getUserA and getUserB have different id-selectors and getJoinedUsers depends on both of them. So, what id is going to use getJoinedUsers in order to cache its results? Redux-Views will infer that by creating a new id-selector that is the combination of those 2. Let's see it in action:

getJoinedUsers(state, {idA: '1', idB: '2'})
getJoinedUsers(state, {idA: '3', idB: '4'})
getJoinedUsers(state, {idA: '2', idB: '1'})
getJoinedUsers(state, {idA: '4', idB: '3'})

getJoinedUsers(state, {idA: '1', idB: '2'})
getJoinedUsers(state, {idA: '3', idB: '4'})
getJoinedUsers(state, {idA: '2', idB: '1'})
getJoinedUsers(state, {idA: '4', idB: '3'})

getJoinedUsers.recomputations() // => 4
getUserA.recomputations() // => 4
getUserB.recomputations() // => 4

Cache Invalidation

Just like reselect does, the selectors created with redux-views expose the following functions:

  • resultFunc
  • recomputations
  • resetRecomputations

On top of those, redux-views also expose these functions:

  • idSelector: the selector that is being used in order to calculate the id of the instance that is consuming the selector.
  • use: a function that receives the id of the instance that is using it (the result of computing idSelector) and returns a function for unsubscribing.
  • clearCache: if you don't want to handle cache-invalidation through ref-counts, you can manually clear the cache using this function. By default it recursively clears the cache and also the cache of its dependencies. If you do not want to clear the cache recursively, use false as the first (and only) argument.

In order to leverage the use function you could use a custom hook:

const usePropsSelector = (pSelector, props) => {
  const { idSelector, use } = pSelector

  const id = idSelector && idSelector(null, props)
  const selector = useCallback(x => pSelector(x, props), [pSelector, id])
  useEffect(() => use && use(id), [use, id])

  return useSelector(selector);
}

Credits to:

  • @voliva: For helping with the definition of the API and for adding the typings.

About

Like Reselect but with better support for shared-selectors

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages

  • JavaScript 79.9%
  • TypeScript 20.1%