├── .babelrc ├── .gitignore ├── LICENSE ├── README.md ├── lib └── index.js ├── package.json └── src └── index.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"], 3 | "plugins": ["transform-object-rest-spread"] 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directory 27 | node_modules 28 | 29 | # Optional npm cache directory 30 | .npm 31 | 32 | # Optional REPL history 33 | .node_repl_history 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 KodersLab 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # topologically-combine-reducers 2 | Inspired by a @gaeron comment at @londonreact, a way to combine reducers by their dependencies and access at their ancestor's values. 3 | 4 | ### How to use? 5 | This module behaves like combineReducers, but takes as second argument an object which defines the dependency tree. 6 | First install via ```npm install topologically-combine-reducers```, then use in your code like so: 7 | ```javascript 8 | import topologicallyCombineReducers from 'topologically-combine-reducers'; 9 | import {auth, users, todos} from './reducers'; 10 | 11 | var masterReducer = topologicallyCombineReducers( 12 | // pass in the object-of-reducers-functions 13 | {auth, users, todos}, 14 | // define the dependency tree 15 | { 16 | users: [], // could be omitted. 17 | auth: ['users'], 18 | todos: ['auth'] 19 | } 20 | ); 21 | ``` 22 | Now, the users, auth and todos reducer will be called with an object as the third argument containing the state tree of the dependent reducers. 23 | 24 | ```javascript 25 | export function users(state = {}, action){ 26 | // ...just handle an object with the id as key 27 | } 28 | 29 | export function auth(loggedUser = null, action, {users}){ 30 | // ...using ES6 destructuring, users will contain the updated users object 31 | } 32 | 33 | export function todos(todos = {}, action, {users, auth}){ 34 | // now, your reducer knows all about users and auth, so you can check if user is logged and exists. 35 | 36 | // (if no auth is provided, or user is missing in users, do nothing.) 37 | if(!auth || !users[auth]) return todos; 38 | 39 | // Hey! Now in your reducer you can handle user_id! :D 40 | switch(action.type){ 41 | // ... 42 | case ADD: 43 | return { 44 | ...todos, 45 | [newId]: { 46 | user_id: auth, 47 | task: action.payload 48 | } 49 | }; 50 | // ... 51 | } 52 | } 53 | ``` 54 | 55 | And what about testing? As you can imagine, to test these reducer you just have to pass as third argument an object of their dependency. 56 | This way you could also handle custom edge-case testing like save a task as unlogged user and handle it after when the user is finally logged. 57 | ```javascript 58 | assert( 59 | // call the reducer 60 | todos( 61 | {}, 62 | addTodoActionCreator('Learn advanced redux usage'), 63 | {auth: '1', users: {'1': {username: 'mattiamanzati'}}} 64 | ), 65 | // expected output 66 | {'1': {user_id: '1', task: 'Learn advanced redux usage'}} 67 | ); 68 | 69 | assert( 70 | // call the reducer 71 | todos( 72 | {}, 73 | addTodoActionCreator('Learn advanced redux usage'), 74 | {auth: '1', users: {}} 75 | ), 76 | // expected output (nothing is done because user does not exists in users object) 77 | {} 78 | ); 79 | ``` 80 | 81 | ### This can solve 82 | - **Accessing ancestor reducer's data** (e.g. accessing current logged user_id) 83 | - **Let the integrity checks live in the reducer** (e.g. if we are adding a todo with a non existing user_id in the users reducers, do nothing.) 84 | - **Writing tests for reducer with dependency to other reducers data** (e.g. auth reducer will depend on users reducer, instead of creating the entire app store for each test, you could simply pass in as third argument of the reducer the state of the users reducer at that time) 85 | - **Time travel problems with redux-thunk** (e.g. action contains some data that cames from getState(), and this may change during time travelling) 86 | - **Writing modular apps in redux** (e.g. each module exports an index.js with the dependencies list and the reducer, and you can construct the masterReducer using that dependencies tree) 87 | 88 | ### The problem 89 | Imagine a multi-user app, with lot of features and a modular structure. 90 | You will mostly have two or three different reducers: the auth reducers, the users reducers and (for example) the todos reducer. 91 | 92 | Your state tree will almost look like these: 93 | ```javascript 94 | { 95 | auth: 'mattiamanzati access token', 96 | usersById: { 97 | 1: {username: 'mattiamanzati', token: 'mattiamanzati access token'}, 98 | 2: {username: 'another user', token: 'another user access token'} 99 | }, 100 | todosById: { 101 | 1: {user_id: 1, task: 'Have a sleep'}, 102 | 2: {user_id: 2, task: 'Have a drink'} 103 | } 104 | } 105 | ``` 106 | 107 | Now when you write down the todos reducer the problem cames along... how do I get the current logged user and automatically append it? Well, you could use redux-thunk and get the current user id with the getState() function and then attach the user id in the action. 108 | But this way you will create two different action creators for a single action, and **there is no integrity check while performing the mutation on the state tree** by the reducer. Also, **using redux-thunk could break time travelling**, because you getState() and re-dispatch an action with the user_id just got. -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | 7 | var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; 8 | 9 | exports.default = topologicallyCombineReducers; 10 | 11 | var _toposortClass = require('toposort-class'); 12 | 13 | var _toposortClass2 = _interopRequireDefault(_toposortClass); 14 | 15 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 16 | 17 | function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } 18 | 19 | function topologicallyCombineReducers(reducers) { 20 | var dependencies = arguments.length <= 1 || arguments[1] === undefined ? {} : arguments[1]; 21 | 22 | // create the toposort class 23 | var ts = new _toposortClass2.default(); 24 | 25 | // add the dependencies into toposort class 26 | Object.keys(reducers).forEach(function (key) { 27 | ts = ts.add(key, dependencies[key] || []); 28 | }); 29 | 30 | // create the processing order 31 | var order = ts.sort().reverse(); 32 | 33 | // return the combined reducer 34 | return function () { 35 | var state = arguments.length <= 0 || arguments[0] === undefined ? {} : arguments[0]; 36 | var action = arguments[1]; 37 | 38 | 39 | // process the reducers and return the newly combined state 40 | return order.reduce(function (state, key) { 41 | var oldChildState = state[key]; 42 | var newChildState = reducers[key](oldChildState, action, state); 43 | 44 | // only create a new combined state if the child state changed 45 | if (oldChildState !== newChildState) { 46 | return _extends({}, state, _defineProperty({}, key, newChildState)); 47 | } 48 | 49 | // otherwise return the old state object 50 | return state; 51 | }, state); 52 | }; 53 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "topologically-combine-reducers", 3 | "version": "0.0.5", 4 | "description": "Combine and access other reducer state by their dependencies", 5 | "main": "lib/index.js", 6 | "jsnext:main": "src/index.js", 7 | "scripts": { 8 | "clean": "rimraf lib", 9 | "build:commonjs": "babel src --out-dir lib", 10 | "build": "npm run clean && npm run build:commonjs", 11 | "prepublish": "npm run clean && npm run build" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/KodersLab/topologically-combine-reducers.git" 16 | }, 17 | "keywords": [ 18 | "redux", 19 | "reducer" 20 | ], 21 | "author": "Mattia Manzati", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/KodersLab/topologically-combine-reducers/issues" 25 | }, 26 | "homepage": "https://github.com/KodersLab/topologically-combine-reducers#readme", 27 | "devDependencies": { 28 | "babel-cli": "^6.4.5", 29 | "babel-plugin-transform-object-rest-spread": "^6.8.0", 30 | "babel-preset-es2015": "^6.3.13", 31 | "rimraf": "^2.5.1" 32 | }, 33 | "dependencies": { 34 | "toposort-class": "^1.0.1" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Toposort from 'toposort-class'; 2 | 3 | export default function topologicallyCombineReducers(reducers, dependencies = {}){ 4 | // create the toposort class 5 | var ts = new Toposort(); 6 | 7 | // add the dependencies into toposort class 8 | Object.keys(reducers) 9 | .forEach(key => { 10 | ts = ts.add(key, dependencies[key] || []) 11 | }); 12 | 13 | // create the processing order 14 | var order = ts.sort().reverse(); 15 | 16 | // return the combined reducer 17 | return (state = {}, action) => { 18 | 19 | // process the reducers and return the newly combined state 20 | return order.reduce((state, key) => { 21 | var oldChildState = state[key]; 22 | var newChildState = reducers[key](oldChildState, action, state); 23 | 24 | // only create a new combined state if the child state changed 25 | if (oldChildState !== newChildState) { 26 | return { ...state, [key]: newChildState }; 27 | } 28 | 29 | // otherwise return the old state object 30 | return state; 31 | }, state); 32 | } 33 | } 34 | --------------------------------------------------------------------------------