Skip to content

Instantly share code, notes, and snippets.

@butchler
Created August 19, 2016 02:04
Show Gist options
  • Save butchler/cb5968d34cc1ad056e296f147633c519 to your computer and use it in GitHub Desktop.
Save butchler/cb5968d34cc1ad056e296f147633c519 to your computer and use it in GitHub Desktop.
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