@jvitela/recompute
v0.3.5
Published
Selector functions based on Observable and Computed values.
Downloads
59
Maintainers
Readme
Recompute
Alternative “selector” library (for Redux and others) inspired by Reselect and Computed properties from MobX, Aurelia and Angular.
Recompute is based on Observers and Selectors. Observers are simple non memoized functions used to read specific state properties. Selectors are memoized functions that compute results based on the values returned by one or more observers.
- Observers provide an interface to read from the state
- Selectors can compute derived data, allowing Redux to store the minimal possible state.
- Selectors are efficient. A selector is not recomputed unless one of its dependencies changes.
- Selectors are composable. They can be used as input to other selectors.
Differences with Reselect
- Selectors can be shared across multiple component instances
- Selectors can take any number of arguments
- Selectors have unbounded cache size
- Selectors use a more intuitive syntax
import { createSelector, createObserver, setState } from '@jvitela/recompute'
const shopItems = createObserver(state => state.shop.items)
const taxPercent = createObserver(state => state.shop.taxPercent)
const subtotal = createSelector(() =>
shopItems().reduce((acc, item) => acc + item.value, 0)
)
const tax = createSelector(() =>
subtotal() * (taxPercent() / 100)
)
const total = createSelector((currency) =>
({ total: subtotal() + tax(), currency })
)
setState({
shop: {
taxPercent: 8,
items: [
{ name: 'apple', value: 1.20 },
{ name: 'orange', value: 0.95 },
]
}
})
console.log(subtotal()) // 2.15
console.log(tax()) // 0.172
console.log(total('EUR')) // { total: 2.322, currency: 'EUR' }
Table of Contents
Installation
npm i @jvitela/recompute
Motivation for Memoized Selectors
The examples in this section are based on the Redux Todos List example.
containers/VisibleTodoList.js
import { connect } from 'react-redux'
import { toggleTodo } from '../actions'
import TodoList from '../components/TodoList'
const getVisibleTodos = (todos, filter) => {
switch (filter) {
case 'SHOW_ALL':
return todos
case 'SHOW_COMPLETED':
return todos.filter(t => t.completed)
case 'SHOW_ACTIVE':
return todos.filter(t => !t.completed)
}
}
const mapStateToProps = (state) => {
return {
todos: getVisibleTodos(state.todos, state.visibilityFilter)
}
}
const mapDispatchToProps = (dispatch) => {
return {
onTodoClick: (id) => {
dispatch(toggleTodo(id))
}
}
}
const VisibleTodoList = connect(
mapStateToProps,
mapDispatchToProps
)(TodoList)
export default VisibleTodoList
In the above example, mapStateToProps
calls getVisibleTodos
to calculate todos
. This works great, but there is a drawback: todos
is calculated every time the state tree is updated. If the state tree is large, or the calculation expensive, repeating the calculation on every update may cause performance problems. Recompute can help to avoid these unnecessary recalculations.
Creating a Memoized Selector
We would like to replace getVisibleTodos
with a memoized selector that recalculates todos
when the value of state.todos
or state.visibilityFilter
changes, but not when changes occur in other (unrelated) parts of the state tree.
Recompute createObserver
creates a function used to read values from the state.
Recompute createSelector
creates a function used to compute a result based on the values from one or more observers.
If the state tree is mutated in a way that causes the value returned by an observer to change, the memoized selector will call its function and return the result. If the values of the observers are the same as the previous call to the memoized selector, it will return the previously computed value instead.
Let's define a memoized selector named getVisibleTodos
to replace the non-memoized version above:
selectors/index.js
import { createObserver, createSelector } from '@jvitela/recompute'
const getVisibilityFilter = createObserver(state => state.visibilityFilter)
const getTodos = createObserver(state => state.todos)
export const getVisibleTodos = createSelector(() => {
const todos = getTodos()
switch (getVisibilityFilter()) {
case 'SHOW_ALL':
return todos
case 'SHOW_COMPLETED':
return todos.filter(t => t.completed)
case 'SHOW_ACTIVE':
return todos.filter(t => !t.completed)
}
})
In the example above, getVisibilityFilter
and getTodos
are observers for state.visibilityFilter
and state.todos
respectively. They are created as ordinary non-memoized functions because their only job is to create an interface to retrieve the value of specific state properties. In general Observers must be simple and unexpensive functions.
getVisibleTodos
on the other hand is a memoized selector. It reads the values from the state and returns the filtered todos list.
Composing Selectors
A memoized selector can itself be invoked inside another memoized selector. Here is getVisibleTodos
being called by another selector that further filters the todos by keyword:
const getVisibleTodosFilteredByKeyword = createSelector(
(keyword) => getVisibleTodos().filter(
todo => todo.text.includes(keyword)
)
)
Connecting a Selector to the Redux Store
If you are using React Redux, you can call selectors as regular functions inside mapStateToProps()
:
containers/VisibleTodoList.js
import { connect } from 'react-redux'
import { toggleTodo } from '../actions'
import TodoList from '../components/TodoList'
import { getVisibleTodos } from '../selectors'
import { setState } from '@jvitela/recompute'
const mapStateToProps = (state) => {
setState(state); // Tell recompute which state will be used by the observers
return {
todos: getVisibleTodos()
}
}
const mapDispatchToProps = (dispatch) => {
return {
onTodoClick: (id) => {
dispatch(toggleTodo(id))
}
}
}
const VisibleTodoList = connect(
mapStateToProps,
mapDispatchToProps
)(TodoList)
export default VisibleTodoList
Accessing React Props in Selectors
This section introduces a hypothetical extension to our app that allows it to support multiple Todo Lists. Please note that a full implementation of this extension requires changes to the reducers, components, actions etc. that aren’t directly relevant to the topics discussed and have been omitted for brevity.
Here is an App
component that renders three VisibleTodoList
component instances, each of which has a listId
prop:
components/App.js
import React from 'react'
import Footer from './Footer'
import AddTodo from '../containers/AddTodo'
import VisibleTodoList from '../containers/VisibleTodoList'
const App = () => (
<div>
<VisibleTodoList listId="1" />
<VisibleTodoList listId="2" />
<VisibleTodoList listId="3" />
</div>
)
Each VisibleTodoList
container should select a different slice of the state depending on the value of the listId
prop, so let’s modify getVisibilityFilter
and getTodos
to accept a listId argument:
selectors/todoSelectors.js
import { createObserver, createSelector } from '@jvitela/recompute'
const getVisibilityFilter = createObserver((state, listId) =>
state.todoLists[listId].visibilityFilter
)
const getTodos = createObserver((state, listId) =>
state.todoLists[listId].todos
)
const getVisibleTodos = createSelector(
listId => {
const todos = getTodos(listId);
const visibilityFilter = getVisibilityFilter(listId);
switch (visibilityFilter) {
case 'SHOW_COMPLETED':
return todos.filter(todo => todo.completed)
case 'SHOW_ACTIVE':
return todos.filter(todo => !todo.completed)
default:
return todos
}
}
)
export default getVisibleTodos
listId
can be passed to getVisibleTodos
from mapStateToProps
:
const mapStateToProps = (state, props) => {
setState(state)
return {
todos: getVisibleTodos(props.listId)
}
}
So now getVisibleTodos
has access to listId
, and everything is working fine.
Using the getVisibleTodos
selector with multiple instances of the VisibleTodoList
container will correctly memoize.
A selector created with createSelector
has an unlimited cache size and can return different cached results depending on the arguments used to invoke to the selector. When the selector's observed state properties change, its internal cache will be cleared.
API
createObserver(resultFunc, options = { isEqual })
Recompute determines if the value returned by resultFunc
has changed between calls using reference equality (===
). Alternatively you can pass a custom isEqual
equality comparator to the options object.
Customize equalityCheck
for createObserver
import isEqual from 'lodash\isEqual'
// Performs a deep comparison between two values to determine if they are equivalent.
const getStateObject = createObserver(state => state.object, { isEqual });
import moment from 'moment'
const isSameDay = (a,b) => (moment(a).diff(b, 'days') === 0)
const getDateStr = createObserver(state => state.date, { isEqual: isSameDay})
Take into account that observers are not memoized and using expensive equality functions would have an impact on performance.
createSelector(resultFunc, options = { cache, serialize })
Selectors created with createSelector
have an unbounded cache size. This means they always store the last result matching its set of arguments. A selector recomputes when invoked with a different set of arguments. You can manually clear its cache with the clearCache
method (See Testing section for details)
Custom cache for selector
The selector expects a cache object with the following methods
- get(key): Return the cache contents associated to given
key
- set(key, value): Stores
value
in the cache forkey
- clear(): Clear all the contents of the cache
class CustomCache {
constructor() { this.contents = {}; }
get(key) { return this.contents[key]; }
set(key, value) { this.contents[key] = value; }
clear() { this.contents = {}; }
};
const selector = createSelector(selectorFn, { cache: new CustomCache() })
Custom cache key serializer for selector
The serializer option is used to generate the cache key. This function receives an array with the arguments used to invoke the selector and must return a key to be used by the cache.
const serialize = args => JSON.stringify(args);
const selector = createSelector(selectorFn, { serialize })
Testing
For a given state and input, a selector should always produce the same output. For this reason they are simple to unit test.
const a = createObserver(state => state.a)
const b = createObserver(state => state.b)
const selector = createSelector(() => ({
c: a() * 2,
d: b() * 3
}))
test("selector unit test", () => {
setState({ a: 1, b: 2 });
assert.deepEqual(selector(), { c: 2, d: 6 })
setState({ a: 2, b: 3 });
assert.deepEqual(selector(), { c: 4, d: 9 })
})
Testing recomputations
It may also be useful to check that the memoization function for a selector works correctly. Each selector has a recomputations method that will return the number of times it has been recomputed:
suite('selector', () => {
let state = { a: 1, b: 2 }
const reducer = (state, action) => (
{
a: action(state.a),
b: action(state.b)
}
)
const a = createObserver(state => state.a)
const b = createObserver(state => state.b)
const selector = createSelector(() => ({
c: a() * 2,
d: b() * 3
}))
const plusOne = x => x + 1
const id = x => x
test("selector unit test", () => {
setState(state = reducer(state, plusOne))
assert.deepEqual(selector(), { c: 4, d: 9 })
setState(state = reducer(state, id))
assert.deepEqual(selector(), { c: 4, d: 9 })
assert.equal(selector.recomputations(), 1)
setState(state = reducer(state, plusOne))
assert.deepEqual(selector(), { c: 6, d: 12 })
assert.equal(selector.recomputations(), 2)
})
})
Mocking composed selectors
If you have selectors composed of many other selectors, you can mock the result of the nested selectors so that you can test each selector without coupling all of your tests to the entire shape of your state.
For example if you have a set of selectors like this:
export const firstSelector = createSelector( ... )
export const secondSelector = createSelector( ... )
export const thirdSelector = createSelector( ... )
export const myComposedSelector = createSelector(() =>
firstSelector() * secondSelector() < thirdSelector()
)
And then a set of unit tests like this:
test("myComposedSelector unit test", () => {
firstSelector.mock().result(1);
secondSelector.mock().result(2);
thirdSelector.mock().result(3);
assert(myComposedSelector(), true)
firstSelector.mock().result(2);
secondSelector.mock().result(2);
thirdSelector.mock().result(1);
assert(myComposedSelector(), false)
})
Test dependency tracking
In order to test dependency tracking of your selectors, you can invoque the method dependencies
that will return an array of observer ids. Each observer has a unique Id which can be accessed directly
const getA = createObserver(() => state.a);
const getB = createObserver(() => state.b);
const getC = createObserver(() => state.c);
const get2B = createSelector(() => getB() * 2);
const get2C = createSelector(() => getC() * 2);
const getA2B = createSelector(() => getA() + get2B());
const getA2C = createSelector(() => getA() + get2C());
const getABC = createSelector(() => (getA2B() + getA2C()) / 2);
assert.equal(getABC(), 6); // Run once to track dependencies
assert.sameMembers(get2B.dependencies(), [getB.id]);
assert.sameMembers(get2C.dependencies(), [getC.id]);
assert.sameMembers(getA2B.dependencies(), [getA.id, getB.id]);
assert.sameMembers(getA2C.dependencies(), [getA.id, getC.id]);
assert.sameMembers(getABC.dependencies(), [getA.id, getB.id, getC.id]);
And in case of observers that take an argument you can call key
with the argument in order
to get the correct dependency id.
const foo = createObserver((state, opt) => state + opt);
const bar = createSelector(() => foo('a') + foo('b'));
bar(); // Run once to track dependencies
assert.sameMembers(
bar.dependencies(),
[ foo.key('a'), foo.key('b') ]
);
Testing cache clearing
Finally, each selector has a clearCache
method that clears the selector cache.
test('Clear cache', () => {
const getA = createObserver(() => 2);
const timesA = createSelector(times => getA() * times);
timesA.mock(2).result(4);
timesA.mock(3).result(6);
assert.equal(timesA(2), 4);
assert.equal(timesA(3), 6);
assert.equal(timesA.recomputations(), 0);
timesA.clearCache();
assert.equal(timesA(2), 4);
assert.equal(timesA(3), 6);
assert.equal(timesA.recomputations(), 2);
assert.equal(timesA(2), 4);
assert.equal(timesA(3), 6);
assert.equal(timesA.recomputations(), 2);
timesA.clearCache();
assert.equal(timesA(2), 4);
assert.equal(timesA(3), 6);
assert.equal(timesA.recomputations(), 4);
})