Skip to content

Commit 43bb98e

Browse files
committed
Adds support for derived state
1 parent 9402010 commit 43bb98e

File tree

6 files changed

+265
-15
lines changed

6 files changed

+265
-15
lines changed

README.md

Lines changed: 82 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,12 @@ store.getState();
2727
## Features
2828

2929
- Quick to set up, easy to use
30-
- Update your state via mutations within your actions
31-
- Async actions supports remote data fetching/persisting
30+
- Update state via simple mutations (thanks [`immer`](https://github.com/mweststrate/immer))
31+
- Derived state
32+
- Async actions for remote data fetching/persisting
33+
- Redux Dev Tools Extension
3234
- Idiomatic Redux under the hood
3335
- Outputs a standard Redux store
34-
- Supports the Redux Dev Tools Extension
3536
- Supports multiple frameworks (e.g. React via `react-redux`)
3637

3738
## TOCs
@@ -46,12 +47,15 @@ store.getState();
4647
- [Modifying state via actions](#modifying-state-via-actions)
4748
- [Dispatching actions](#dispatching-actions)
4849
- [Asynchronous actions](#asynchronous-actions)
50+
- [Deriving state](#deriving-state)
51+
- [Accessing Derived State](#accessing-derived-state)
4952
- [Final notes](#final-notes)
5053
- [Usage with React](#usage-with-react)
5154
- [API](#api)
5255
- [createStore(model, config)](#createstoremodel-config)
5356
- [action](#action)
5457
- [effect(action)](#effectaction)
58+
- [select(selector)](#select)
5559
- [Prior Art](#prior-art)
5660

5761
## Introduction
@@ -183,6 +187,39 @@ store.dispatch.todos.saveTodo('Install easy-peasy').then(() => {
183187
})
184188
```
185189

190+
### Deriving state
191+
192+
If you have state that can be derived from state then you can use the [`select`](#select(selector)) helper. Simply attach it to any part of your model.
193+
194+
```javascript
195+
import { select } from 'easy-peasy'; // 👈 import then helper
196+
197+
const store = createStore({
198+
shoppingBasket: {
199+
products: [{ name: 'Shoes', price: 123 }, { name: 'Hat', price: 75 }],
200+
totalPrice: select(state =>
201+
state.products.reduce((acc, cur) => acc + cur.price, 0)
202+
)
203+
}
204+
}
205+
```
206+
207+
The derived data will be cached and will only be recalculated when the associated state changes.
208+
209+
This can be really helpful to avoid unnecessary re-renders in your react components, especially when you do things like converting an object map to an array in your `connect`. Typically people would use [`reselect`](https://github.com/reduxjs/reselect) to alleviate this issue, however, with Easy Peasy it's this feature is baked right in.
210+
211+
You can attach selectors to any part of your state. Similar to actions they will receive the local state that they are attached to and can access all the state down that branch of state.
212+
213+
### Accessing Derived State
214+
215+
It's as simple as a standard get state call.
216+
217+
```javascript
218+
store.getState().shoppingBasket.totalPrice
219+
```
220+
221+
> Note! See how we don't call the derived state as a function. You access it as a simple property.
222+
186223
### Final notes
187224
188225
This was just a brief overview of how to create and interact with an Easy Peasy store. We recommend that you read the section on [Usage with React](#usage-with-react) to see how to effectively use this library in the context of React. Also be sure to check out and tinker with our [examples](#examples).
@@ -371,11 +408,12 @@ When your model is processed by Easy Peasy to create your store all of your acti
371408
#### Example
372409
373410
```javascript
374-
import { createStore, effect } from 'easy-peasy';
411+
import { createStore, effect } from 'easy-peasy'; // 👈 import then helper
375412

376413
const store = createStore({
377414
session: {
378415
user: undefined,
416+
// 👇 define your effectful action
379417
login: effect(async (dispatch, payload) => {
380418
const user = await loginService(payload)
381419
dispatch.session.loginSucceeded(user)
@@ -387,12 +425,49 @@ const store = createStore({
387425
});
388426

389427
// 👇 you can dispatch and await on the effectful actions
390-
await store.dispatch.session.login({
428+
store.dispatch.session.login({
391429
username: 'foo',
392430
password: 'bar'
393-
});
431+
})
432+
// 👇 effectful actions _always_ return a Promise
433+
.then(() => console.log('Logged in'));
434+
435+
```
436+
437+
### select(selector)
438+
439+
Declares a section of state that is derived via the given selector function.
440+
441+
#### Arguments
442+
443+
- selector (Function, required)
444+
445+
The selector function responsible for resolving the derived state. It will be provided the following arguments:
446+
447+
- `state` (Object, required)
448+
449+
The local part of state that the `select` property was attached to.
450+
451+
Select's have their outputs cached to avoid unnecessary work, and will be executed
452+
any time their local state changes.
453+
454+
#### Example
455+
456+
```javascript
457+
import { select } from 'easy-peasy'; // 👈 import then helper
458+
459+
const store = createStore({
460+
shoppingBasket: {
461+
products: [{ name: 'Shoes', price: 123 }, { name: 'Hat', price: 75 }],
462+
// 👇 define your derived state
463+
totalPrice: select(state =>
464+
state.products.reduce((acc, cur) => acc + cur.price, 0)
465+
)
466+
}
467+
}
394468

395-
console.log('Logged in');
469+
// 👇 access the derived state as you would normal state
470+
store.getState().shoppingBasket.totalPrice
396471
```
397472
398473
## Prior art

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"peerDependencies": {},
3232
"dependencies": {
3333
"immer": "^1.7.2",
34+
"memoize-one": "^4.0.2",
3435
"redux": "^4.0.1",
3536
"redux-thunk": "^2.3.0"
3637
},

src/__tests__/easy-peasy.test.js

Lines changed: 135 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/* eslint-disable no-param-reassign */
22

3-
import { createStore, effect } from '../index'
3+
import { createStore, effect, select } from '../index'
44

55
const resolveAfter = (data, ms) =>
66
new Promise(resolve => setTimeout(() => resolve(data), ms))
@@ -119,6 +119,25 @@ test('nested action', () => {
119119
})
120120
})
121121

122+
test('root action', () => {
123+
// arrange
124+
const store = createStore({
125+
todos: {
126+
items: { 1: { text: 'foo' } },
127+
},
128+
doSomething: state => {
129+
state.todos.items[2] = { text: 'bar' }
130+
},
131+
})
132+
133+
// act
134+
store.dispatch.doSomething()
135+
136+
// assert
137+
const actual = store.getState().todos.items
138+
expect(actual).toEqual({ 1: { text: 'foo' }, 2: { text: 'bar' } })
139+
})
140+
122141
test('redux thunk configured', async () => {
123142
// arrange
124143
const model = { foo: 'bar' }
@@ -343,3 +362,118 @@ test('dispatches an action to represent the start of an effect', () => {
343362
// assert
344363
expect(trackActions.actions).toEqual([{ type: 'foo.doSomething', payload }])
345364
})
365+
366+
describe('select', () => {
367+
test('is run for initialisation of store', () => {
368+
// arrange
369+
const selector = jest.fn()
370+
selector.mockImplementation(state =>
371+
Object.keys(state.items).map(key => state.items[key]),
372+
)
373+
374+
// act
375+
const store = createStore({
376+
items: { 1: { text: 'foo' } },
377+
itemList: select(selector),
378+
})
379+
380+
// assert
381+
const actual = store.getState().itemList
382+
expect(actual).toEqual([{ text: 'foo' }])
383+
expect(selector).toHaveBeenCalledTimes(1)
384+
})
385+
386+
test('executes one only if state does not change', () => {
387+
// arrange
388+
const selector = jest.fn()
389+
selector.mockImplementation(state =>
390+
Object.keys(state.items).map(key => state.items[key]),
391+
)
392+
const store = createStore({
393+
items: { 1: { text: 'foo' } },
394+
itemList: select(selector),
395+
doNothing: () => undefined,
396+
})
397+
398+
// act
399+
store.dispatch.doNothing()
400+
401+
// assert
402+
const actual = store.getState().itemList
403+
expect(actual).toEqual([{ text: 'foo' }])
404+
expect(selector).toHaveBeenCalledTimes(1)
405+
})
406+
407+
test('executes again if state does change', () => {
408+
// arrange
409+
const selector = jest.fn()
410+
selector.mockImplementation(state =>
411+
Object.keys(state.items).map(key => state.items[key]),
412+
)
413+
const store = createStore({
414+
items: { 1: { text: 'foo' } },
415+
itemList: select(selector),
416+
doSomething: state => {
417+
state.items[2] = { text: 'bar' }
418+
},
419+
})
420+
421+
// act
422+
store.dispatch.doSomething()
423+
424+
// assert
425+
const actual = store.getState().itemList
426+
expect(actual).toEqual([{ text: 'foo' }, { text: 'bar' }])
427+
expect(selector).toHaveBeenCalledTimes(2)
428+
})
429+
430+
test('executes if parent action changes associated state', () => {
431+
// arrange
432+
const selector = jest.fn()
433+
selector.mockImplementation(state =>
434+
Object.keys(state.items).map(key => state.items[key]),
435+
)
436+
const store = createStore({
437+
todos: {
438+
items: { 1: { text: 'foo' } },
439+
itemList: select(selector),
440+
},
441+
doSomething: state => {
442+
state.todos.items[2] = { text: 'bar' }
443+
},
444+
})
445+
446+
// act
447+
store.dispatch.doSomething()
448+
449+
// assert
450+
const actual = store.getState().todos.itemList
451+
expect(actual).toEqual([{ text: 'foo' }, { text: 'bar' }])
452+
expect(selector).toHaveBeenCalledTimes(2)
453+
})
454+
455+
test('root select', () => {
456+
// arrange
457+
const selector = jest.fn()
458+
selector.mockImplementation(state =>
459+
Object.keys(state.todos.items).map(key => state.todos.items[key]),
460+
)
461+
const store = createStore({
462+
todos: {
463+
items: { 1: { text: 'foo' } },
464+
},
465+
itemList: select(selector),
466+
doSomething: state => {
467+
state.todos.items[2] = { text: 'bar' }
468+
},
469+
})
470+
471+
// act
472+
store.dispatch.doSomething()
473+
474+
// assert
475+
const actual = store.getState().itemList
476+
expect(actual).toEqual([{ text: 'foo' }, { text: 'bar' }])
477+
expect(selector).toHaveBeenCalledTimes(2)
478+
})
479+
})

src/easy-peasy.js

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,13 @@ import {
33
compose,
44
createStore as reduxCreateStore,
55
} from 'redux'
6+
import memoizeOne from 'memoize-one'
67
import produce from 'immer'
78
import thunk from 'redux-thunk'
89

10+
const effectSymbol = Symbol('effect')
11+
const selectSymbol = Symbol('select')
12+
913
const isObject = x => x && typeof x === 'object' && !Array.isArray(x)
1014

1115
const get = (path, target) =>
@@ -22,14 +26,18 @@ const set = (path, target, value) => {
2226
}, target)
2327
}
2428

25-
const effectSymbol = Symbol('effect')
26-
2729
export const effect = fn => {
2830
// eslint-disable-next-line no-param-reassign
2931
fn[effectSymbol] = true
3032
return fn
3133
}
3234

35+
export const select = fn => {
36+
const selector = memoizeOne(state => fn(state))
37+
selector[selectSymbol] = true
38+
return selector
39+
}
40+
3341
export const createStore = (model, options = {}) => {
3442
const { devTools = true, middleware = [], initialState = {} } = options
3543

@@ -44,8 +52,9 @@ export const createStore = (model, options = {}) => {
4452
const references = {}
4553
const defaultState = {}
4654
const actionEffects = {}
47-
const actionReducers = {}
4855
const actionCreators = {}
56+
const actionReducers = {}
57+
const selectorReducers = []
4958

5059
const extract = (current, parentPath) =>
5160
Object.keys(current).forEach(key => {
@@ -54,7 +63,10 @@ export const createStore = (model, options = {}) => {
5463
if (typeof value === 'function') {
5564
const actionName = path.join('.')
5665

57-
if (value[effectSymbol]) {
66+
if (value[selectSymbol]) {
67+
// skip
68+
selectorReducers.push([parentPath, key, value])
69+
} else if (value[effectSymbol]) {
5870
// Effect Action
5971
const action = payload => {
6072
references.dispatch({
@@ -124,7 +136,7 @@ export const createStore = (model, options = {}) => {
124136
key,
125137
createReducers(current[key], [...path, key]),
126138
])
127-
return (state = get(path, defaultState), action) => {
139+
const reducerForActions = (state = get(path, defaultState), action) => {
128140
const actionReducer = actionReducersAtPath.find(
129141
x => x.actionName === action.type,
130142
)
@@ -143,6 +155,29 @@ export const createStore = (model, options = {}) => {
143155
}
144156
return state
145157
}
158+
let isInitial = true
159+
return selectorReducers.length
160+
? (state, action) => {
161+
const stateAfterActions = reducerForActions(state, action)
162+
if (state !== stateAfterActions || isInitial) {
163+
const stateAfterSelectors = selectorReducers.reduce(
164+
(acc, [parentPath, key, selector]) =>
165+
produce(acc, draft => {
166+
// eslint-disable-next-line no-param-reassign
167+
const target =
168+
parentPath.length > 0 ? get(parentPath, draft) : draft
169+
if (target) {
170+
target[key] = selector(target)
171+
}
172+
}),
173+
stateAfterActions,
174+
)
175+
isInitial = false
176+
return stateAfterSelectors
177+
}
178+
return stateAfterActions
179+
}
180+
: reducerForActions
146181
}
147182

148183
const reducers = createReducers(actionReducers, [])

0 commit comments

Comments
 (0)