taskworld / rereselect

A library that generates memoized selectors (like Reselect) which supports dynamic dependency tracking (à la Vue/VueX/MobX).
23 stars 1 forks source link

rereselect

Not to be confused with Re-reselect which is an enhancement to Reselect. This is an entirely separate project.

A library that generates memoized selectors like Reselect but:

Design constraints:

Notes:

Installation

npm install @taskworld.com/rereselect

Motivation

Why a new selector library?

Here’s an example. Let’s say we have a list of online user IDs and a mapping from user ID to user’s information. We want to select a list of online users, as in this example:

A selector that selects a list of online users. It depends on `state.onlineUserIds` and for each user ID in the latter, `state.users[name]`.

Creating such selector is impossible using Reselect, because in Reselect, selectors must declare their dependencies statically upfront. Since we couldn’t know in advance which users will be online, we need to declare a dependency on the whole users branch of the state tree:

Instead of depending only on relevant users, we had to depend on the whole `state.users` branch.

This results in a selector that looks like this:

const selectOnlineUsers = createSelector(
  state => state.onlineUserIds,
  state => state.users,
  (onlineUserIds, users) => {
    return onlineUserIds.map(id => users[id])
  }
)

This works, but this means that changes to unrelated users (bob, charlie, eve) will cause the selector to be recomputed. This problem has been asked multiple times with no efficient and elegant solution.

With rereselect, selectors don’t declare their dependencies upfront. Instead, they are inlined in the selection logic:

const selectOnlineUsers = makeSelector(query => {
  const userIds = query(state => state.onlineUserIds)
  return userIds.map(id => query(state => state.users[id]))
})

The selection logic will receive a function query which can be used to invoke other selectors. In doing so, the dependency will be tracked automatically. This allows more fine-grained control over which part of the state tree would cause the selector to be recomputed.

Differences from Reselect

The Reselect “shopping cart” example:

import { makeSelector } from '@taskworld.com/rereselect'

// “Simple” selectors are the same.
const shopItemsSelector = state => state.shop.items
const taxPercentSelector = state => state.shop.taxPercent

// Instead of `createSelector`, it is called `makeSelector`.
//
// Instead of declaring dependencies upfront, use the `query` function
// to invoke other selectors. In doing so, the dependency will
// automatically be tracked.
//
const subtotalSelector = makeSelector(query =>
  query(shopItemsSelector).reduce((acc, item) => acc + item.value, 0)
)
const taxSelector = makeSelector(
  query => query(subtotalSelector) * (query(taxPercentSelector) / 100)
)
const totalSelector = makeSelector(query => ({
  total: query(subtotalSelector) + query(taxSelector),
}))

Dynamic dependency tracking:

let state = {
  fruits: {
    a: { name: 'Apple' },
    b: { name: 'Banana' },
    c: { name: 'Cantaloupe' },
  },
  selectedFruitIds: ['a', 'c'],
}

// I want to query the selected fruits...
const selectSelectedFruits = makeSelector(query =>
  query(state => state.selectedFruitIds).map(id =>
    query(state => state.fruits[id])
  )
)

// Use like any other selectors:
console.log(selectSelectedFruits(state)) // [ { name: 'Apple' }, { name: 'Cantaloupe' } ]

// Since data selection is fine-grained, changes to unrelated parts
// of the state will not cause a recomputation.
state = {
  ...state,
  fruits: {
    ...state.fruits,
    b: { name: 'Blueberry' },
  },
}
console.log(selectSelectedFruits(state)) // [ { name: 'Apple' }, { name: 'Cantaloupe' } ]
console.log(selectSelectedFruits.recomputations()) // 1

Reimplementing Reselect’s createSelector on top of rereselect:

function createSelector(...funcs) {
  const resultFunc = funcs.pop()
  const dependencies = Array.isArray(funcs[0]) ? funcs[0] : funcs
  return makeSelector(query => resultFunc(...dependencies.map(query)))
}

Performance comparison

In “cache hit” scenarios, rereselect is faster than Reselect. The numbers below are in million-operation-per-second.

Scenario Reselect v3 Reselect v5 rereselect
cache hit (same state) 15.6 16.6 17.1
cache hit (shallowly equal deps) 5.5 1.1 7.6~9.8
cache miss 3.9 0.5 3.8

Build your own abstraction

This library is only concerned with creating a selector system that supports dynamic dependency tracking. It provides a building blocks for which higher-level abstractions can be built upon. So, it is up to you to built your tooling on top of this.

Please read the test to see some of the real-world usage scenarios.

Parameterized selectors

This is how we do it (we also added displayName property to our selectors to make them easier to debug):

export function makeParameterizedSelector(
  displayName,
  selectionLogicGenerator
) {
  const memoized = new Map()
  return Object.assign(
    function selectorFactory(...args) {
      const key = args.join(',')
      if (memoized.has(key)) return memoized.get(key)!
      const name = `${displayName}(${key})`
      const selectionLogic = selectionLogicGenerator(...args)
      const selector = makeSelector(selectionLogic)
      selector.displayName = name
      memoized.set(key, selector)
      return selector
    },
    { displayName }
  )
}

API

Please read the test.