Created
August 19, 2016 02:04
-
-
Save butchler/cb5968d34cc1ad056e296f147633c519 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { createStore } from 'redux'; | |
/** | |
* This is my attempt at creating a solution for the problem of scoping actions to specific | |
* items/entities inside of nested reducers. | |
* | |
* You can use withPath(action) to add a 'path' property to an action that defines its scope in the | |
* nested state. | |
* | |
* Then your reducers can use combineReducersWithPath() to pass an appropriately appended third | |
* 'path' argument to the reducers, and reduceWithPath() to reduce the state only if the reducer's | |
* path matches the action's path. | |
*/ | |
// Usage | |
// ===== | |
const store = createStore(combineReducersWithPath({ | |
items: itemsReducer, | |
})); | |
store.dispatch(withPath(['items'], addItem('a'))); | |
store.dispatch(withPath(['items', 'a'], addSubItem('b'))); | |
store.dispatch(withPath(['items', 'a', 'b'], addSubSubItem('c', 'data'))); | |
// The final state should be: { items: { a: { b: { c: 'data' } } } } | |
console.log(store.getState()); | |
// Reducers | |
function itemsReducer(state = {}, action, path) { | |
state = reduceWithPath(itemReducer, state, action, path); | |
if (action.type === 'ADD_ITEM') { | |
// Add the item. | |
return Object.assign({}, state, { [action.payload]: itemReducer(undefined, action) }); | |
} | |
return state; | |
} | |
function itemReducer(state = {}, action, path) { | |
state = reduceWithPath(subItemReducer, state, action, path); | |
if (action.type === 'ADD_SUB_ITEM') { | |
// Add the sub item. | |
return Object.assign({}, state, { [action.payload]: subItemReducer(undefined, action) }); | |
} | |
return state; | |
} | |
function subItemReducer(state = {}, action, path) { | |
if (action.type === 'ADD_SUB_SUB_ITEM') { | |
// Add the sub item's sub item. | |
return Object.assign({}, state, { [action.payload.id]: action.payload.data }); | |
} | |
return state; | |
} | |
// Action creators | |
function addItem(id) { | |
return { type: 'ADD_ITEM', payload: id }; | |
} | |
function addSubItem(id) { | |
return { type: 'ADD_SUB_ITEM', payload: id }; | |
} | |
function addSubSubItem(id, data) { | |
return { type: 'ADD_SUB_SUB_ITEM', payload: { id, data } }; | |
} | |
// Public API | |
// ========== | |
/** | |
* Takes a path and an action and returns a new action with the given path assigned. | |
* | |
* This is meant to be used with flux standard actions style actions, so that we know that all of | |
* the information specific to the action is in the payload and we're not accidentally clobbering | |
* one of the action's properties. I think that the ability to add generic properties to actions | |
* without messing up the action-specific payload is one of the main advantages of flux standard | |
* actions. | |
*/ | |
function withPath(path, action) { | |
return Object.assign({}, action, { path: concatPaths(action.path, path) }); | |
} | |
/** | |
* Same as combineReducers, but also passes a path argument concatenated with the corresponding key | |
* for each reducer. | |
*/ | |
function combineReducersWithPath(reducers) { | |
return (state = {}, action, path) => { | |
const newState = {}; | |
Object.keys(reducers).forEach(key => { | |
const reducer = reducers[key]; | |
newState[key] = reducer(state[key], action, concatPaths(path, [key])); | |
}); | |
return newState; | |
} | |
} | |
/** | |
* Calls the given reducer on the corresponding key in the given state if the given action has a | |
* path that matches the given path. | |
*/ | |
function reduceWithPath(reducer, state, action, path) { | |
if (action.path) { | |
const nextPath = getNextPath(action.path, path); | |
if (nextPath) { | |
const key = nextPath[nextPath.length - 1]; | |
// getKey may throw an InvalidPathError if the key doesn't exist on the state. The parent | |
// reducer that calls reduceWithPath may then catch this error and handle it how it sees fit. | |
const subState = getKey(state, key); | |
const newSubState = reducer(subState, action, nextPath); | |
return Object.assign({}, state, { [key]: newSubState }); | |
} | |
} | |
return state; | |
} | |
// Helper functions | |
// ================ | |
function getNextPath(targetPath, currentPath) { | |
if (currentPath && targetPath && | |
targetPath.length > currentPath.length && | |
arrayBeginsWith(targetPath, currentPath)) { | |
return targetPath.slice(0, currentPath.length + 1); | |
} else { | |
return null; | |
} | |
} | |
function arrayBeginsWith(array, beginsWithArray) { | |
for (let i = 0; i < beginsWithArray.length; i++) { | |
if (array[i] !== beginsWithArray[i]) { | |
return false; | |
} | |
} | |
return true; | |
} | |
function concatPaths(a, b) { | |
a = a || []; | |
return a.concat(b); | |
} | |
/** | |
* A helper for getting a key of an object that works with objects, Maps, and Immutable Maps. | |
* | |
* Throws an error if the object doesn't have the given key. | |
*/ | |
function getKey(object, key) { | |
if (typeof object.get === 'function' && typeof object.has === 'function') { | |
// Handle Maps and Immutable Maps. | |
if (!object.has(key)) { | |
throw new InvalidPathError(); | |
} | |
return object.get(key); | |
} else { | |
// Handle plain objects. | |
if (!object.hasOwnProperty(key)) { | |
throw new InvalidPathError(); | |
} | |
return object[key]; | |
} | |
} | |
function InvalidPathError() { | |
this.name = 'InvalidPathError'; | |
this.stack = (new Error()).stack; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment