├── .babelrc ├── .gitignore ├── LICENSE ├── package.json ├── src ├── ClassyRedux.js └── index.js ├── test └── ClassyRedux.spec.js └── README.md /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "transform-class-properties", 4 | "transform-es2015-destructuring", 5 | "transform-object-rest-spread" 6 | ], 7 | "presets": [ 8 | "es2015" 9 | ], 10 | "ignore": [ 11 | "node_modules" 12 | ] 13 | } -------------------------------------------------------------------------------- /.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 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Jeremy Walker 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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "classy-redux", 3 | "version": "1.0.12", 4 | "description": "Uses OOP to simplify Redux reducer logic", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "build": "babel src/ClassyRedux.js --out-file src/index.js", 8 | "test": "npm run build; mocha --compilers js:babel-register --ui bdd test/ClassyRedux.spec.js" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/machineghost/Classy-Redux.git" 13 | }, 14 | "keywords": [ 15 | "redux" 16 | ], 17 | "author": "Jeremy Walker", 18 | "license": "MIT", 19 | "bugs": { 20 | "url": "https://github.com/machineghost/Classy-Redux/issues" 21 | }, 22 | "homepage": "https://github.com/machineghost/Classy-Redux#readme", 23 | "devDependencies": { 24 | "babel-cli": "^6.18.0", 25 | "babel-plugin-transform-class-properties": "^6.11.5", 26 | "babel-plugin-transform-es2015-destructuring": "^6.18.0", 27 | "babel-plugin-transform-object-rest-spread": "^6.16.0", 28 | "babel-preset-es2015": "^6.14.0", 29 | "chai": "^3.5.0", 30 | "mocha": "^3.1.2", 31 | "sinon": "^1.17.6" 32 | }, 33 | "dependencies": { 34 | "lodash": "^4.17.2", 35 | "redux": "^3.6.0" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/ClassyRedux.js: -------------------------------------------------------------------------------- 1 | import { 2 | combineReducers, 3 | compose, 4 | createStore 5 | } from 'redux'; 6 | import { 7 | camelCase, 8 | cloneDeep, 9 | isUndefined 10 | } from 'lodash'; 11 | 12 | export class ReducerBuilder { 13 | constructor() { 14 | this.build(); 15 | } 16 | 17 | /** 18 | * Provides the initial state of the reducer. This initial state can either be a static object or 19 | * a getter method which returns the initial state (like the default implementation). 20 | */ 21 | get initialState() { 22 | return {}; 23 | } 24 | 25 | /** 26 | * This method (which by default is a no-op) can be overriden to add any sort of 27 | * "post-processing" after an action handler has been applied 28 | */ 29 | afterAction(action, newState) { 30 | } 31 | 32 | /** 33 | * This method (which by default is a no-op) can be overriden to add any sort of 34 | * "pre-processing" before an action handler has been applied 35 | */ 36 | beforeAction(action, newState) { 37 | } 38 | 39 | /** 40 | * Binds the reducer function to the ReducerBuilder. Can be overriden to apply other reducer 41 | * wrapping logic (eg. Redux-Undo) 42 | */ 43 | build() { 44 | this.reducer = this.reducer.bind(this); 45 | } 46 | 47 | /** 48 | * Returns a clone of the provided state 49 | */ 50 | clone(oldState) { 51 | return cloneDeep(oldState); 52 | } 53 | 54 | /** 55 | * Returns the matching method for the provided action type, if any. 56 | * "Matching" in this case means the method with a name that is the same as 57 | * the provided action type, except camel-cased instead of underscored. 58 | * 59 | * NOTE: Redux actions look like "@@redux/INIT" but clearly neither "@@" nor 60 | * "/" can be converted to camel-case (ie. we can't have a 61 | * "@@redux/Init" method). For this reason, handlers for Redux actions 62 | * should leave out both the "@@" and the "/". For instance, the 63 | * handler for the redux init action should be "reduxInit". 64 | * @param {string} actionType - an string describing an action, in Redux 65 | * standard (ie. capitalized and underscored) format 66 | * @returns {function} - the appropriate handler for the provided action 67 | * type, if any 68 | */ 69 | _getHandler(actionType) { 70 | if (actionType.indexOf('@@') === 0) { 71 | // There's no way to convert @@ to camel case, so discard it (and ditto for "/") 72 | actionType = actionType.substr(2).replace('/'); 73 | } 74 | return this[camelCase(actionType)]; 75 | } 76 | 77 | /** 78 | * Determines whether the provided action is one generated by Redux itself 79 | */ 80 | _isReduxInitAction({type}) { 81 | return type.startsWith('@@redux'); 82 | } 83 | 84 | /** 85 | * The default reducer for all reducerBuilders. It: 86 | * A) generates a new state as a deep clone of the current state 87 | * B) finds the appropriate handling method for the provided action 88 | * C) invokes it (unless it was a non-Redux action and no handler was found 89 | * in which case it throws an eror) 90 | * 91 | * NOTE: Obviously cloning everything on every action is a bit overkill. If 92 | * this ever becomes a performance problem this method can simply be 93 | * overwritten to use a more performant method of creating the new 94 | * state. 95 | * 96 | * 97 | * @param oldState - the current state 98 | * @param action - an object with a type property that desscribes the action 99 | * @returns {array|object} - the new state 100 | */ 101 | reducer(oldState, action) { 102 | let newState = this.clone(isUndefined(oldState) ? this.initialState : oldState); 103 | if (this._isReduxInitAction(action)) return newState; 104 | 105 | const handler = this._getHandler(action.type); 106 | if (!handler) return newState; 107 | 108 | // Create a variable to hold data that will only live for a single reducer cycle 109 | // TODO: Change "reduction" to "_"? this._ = {}; is shorter/easier 110 | this.reduction = {}; 111 | 112 | newState = this.beforeAction(action, newState) || newState; 113 | newState = handler.call(this, action, newState) || newState; 114 | newState = this.afterAction(action, newState)|| newState; 115 | return this._state = newState; 116 | } 117 | 118 | /** 119 | * Of course the actual state is stored in Redux, not in this class, but we keep a copy of 120 | * the last state given to Redux as a convenience, which can be accessed through this getter. 121 | */ 122 | get state() { 123 | return this._state; 124 | } 125 | } 126 | 127 | const isReducer = (possible) => possible.prototype instanceof ReducerBuilder; 128 | const isNotReducer = (possible) => !isReducer(possible); 129 | 130 | /** 131 | * Simple convenience class for creating a store from ReducerBuilder classes instead of 132 | * reducer functions. 133 | * 134 | * @param {ReducerBuilder[]|function[]} reducerBuildersAndMiddleware - 1+ Reducer builders and 0+ 135 | * middleware functions, all of which will be combined to create the store 136 | * 137 | * @example 138 | * const storeBuilder = new StoreBuilder(builder1, middleware1, builder2, middleware2, ...); 139 | * const reduxStore = storeBuilder.store; 140 | */ 141 | export class StoreBuilder { 142 | constructor(...reducerBuildersAndMiddleware) { 143 | this._middleware = reducerBuildersAndMiddleware.filter(isNotReducer); 144 | this._buildMiddleware(); 145 | 146 | this._reducerBuilderClasses = reducerBuildersAndMiddleware.filter(isReducer); 147 | this._buildReducers(); 148 | } 149 | _buildMiddleware(reducerBuildersAndMiddleware) { 150 | this._composedMiddleware = this.compose(...this._middleware); 151 | } 152 | _buildReducers() { 153 | // NOTE: We don't *need* to be storing all of these as properties of the store builder ... 154 | // ... but it makes testing a whole lot easier (vs. using local variables) 155 | this._reducerBuilders = this._reducerBuilderClasses.map((Builder) => new Builder()); 156 | this._reducers = this._reducerBuilders.reduce((reducers, reducerBuilder) => { 157 | if (!reducerBuilder.stateName) throw new Error(`Every reducer builder must have a `+ 158 | `stateName to serve as its key in `+ 159 | `the store state`); 160 | reducers[reducerBuilder.stateName] = reducerBuilder.reducer; 161 | return reducers; 162 | }, {}); 163 | this._combinedReducers = this.combineReducers(this._reducers); 164 | } 165 | /** 166 | * Simple alias for Redux's combineReducers (that can more easily be stubbed when testing). 167 | * Hypothetically if someone wanted to use a custom "combineReducers" function, this would be 168 | * the place to do it. 169 | */ 170 | combineReducers(...args) { 171 | return combineReducers(...args); 172 | } 173 | /** 174 | * Simple alias for Redux's compose (that can more easily be stubbed when testing). 175 | * Hypothetically if someone wanted to use a custom "compose" function, this would be the place 176 | * to do it. 177 | */ 178 | compose(...args) { 179 | return compose(...args); 180 | } 181 | /** 182 | * Returns a version of createStore that has been "enhanced" with the provided middleware. 183 | */ 184 | get createStore() { 185 | return this._createStore = this._createStore || this._composedMiddleware(createStore); 186 | } 187 | get store() { 188 | return this._store = this._store || this.createStore(this._combinedReducers); 189 | } 190 | } -------------------------------------------------------------------------------- /test/ClassyRedux.spec.js: -------------------------------------------------------------------------------- 1 | import {expect} from 'chai'; 2 | import sinon from 'sinon'; 3 | import {ReducerBuilder, StoreBuilder} from '../src/index.js'; 4 | 5 | // DON'T FORGET: These tests run against the *BUILT* version of the library 6 | 7 | describe(`ClassyRedux`, () => { 8 | let sandbox; 9 | beforeEach(() => { 10 | sandbox = sinon.sandbox.create(); 11 | }); 12 | afterEach(() => { 13 | sandbox.restore(); 14 | }); 15 | describe(`ReducerBuilder`, () => { 16 | let builder; 17 | beforeEach(() => { 18 | class TestReducerBuilder extends ReducerBuilder { 19 | get initialState() { 20 | return { fake: 'state' }; 21 | } 22 | noopAction() { 23 | 24 | } 25 | } 26 | builder = new TestReducerBuilder(); 27 | ReducerBuilder.prototype.someAction = sandbox.stub(); 28 | }); 29 | describe(`#constructor`, () => { 30 | it(`can be instantiated`, () => { 31 | expect(builder).to.be.ok; 32 | }); 33 | }); 34 | describe(`#build`, () => { 35 | it(`binds the reducer to the reducer builder`, () => { 36 | const bind = sandbox.stub().returns(`bound`); 37 | builder.reducer = {bind}; 38 | builder.build(); 39 | expect(bind.calledWith(builder)).to.be.true; 40 | expect(builder.reducer).to.equal(`bound`); 41 | }); 42 | }); 43 | describe(`#clone`, () => { 44 | it(`produces a clone which does not mutate the original`, () => { 45 | const original = {a: 1, b: {c: 2}}; 46 | const clone = builder.clone(original); 47 | delete clone.a; 48 | clone.b.c = 3; 49 | clone.d = 'foo'; 50 | expect(original).to.eql({a: 1, b: {c: 2}}); 51 | }); 52 | }); 53 | describe(`#reducer`, () => { 54 | describe(`when passed a Redux-generated ("@@redux") action`, () => { 55 | it(`returns a clone of the previous state`, () => { 56 | builder.clone = () => `foo`; 57 | expect(builder.reducer({}, { type: "@@redux/INIT" })).to.equal(`foo`); 58 | }); 59 | }); 60 | describe(`when passed a non-Redux-generated (ie. not "@@redux") action`, () => { 61 | describe(`#when there is no matching action handler`, () => { 62 | it(`returns the existing state`, () => { 63 | const newState = builder.reducer({ fake: 'state' }, { type: `invalid` }); 64 | expect(newState).to.eql({ fake: 'state' }); 65 | }); 66 | }); 67 | describe(`#when there is a matching action handler`, () => { 68 | beforeEach(() => { 69 | builder.beforeAction = sandbox.stub(); 70 | builder.someAction = sandbox.stub(); 71 | builder.afterAction = sandbox.stub(); 72 | }); 73 | it(`clears the "reduction" state`, () => { 74 | builder.reduction = `something`; 75 | builder.reducer({}, {type: `SOME_ACTION`}); 76 | expect(builder.reduction).to.eql({}); 77 | }); 78 | it(`calls the beforeAction and sets/returns the new state from it`, () => { 79 | builder.beforeAction.returns(`new state`); 80 | expect(builder.reducer({}, {type: `SOME_ACTION`})).to.equal(`new state`); 81 | expect(builder.state).to.equal(`new state`); 82 | }); 83 | it(`calls the action handler and sets/returns the new state from it`, () => { 84 | builder.someAction.returns(`new state`); 85 | expect(builder.reducer({}, {type: `SOME_ACTION`})).to.equal(`new state`); 86 | expect(builder.state).to.equal(`new state`); 87 | }); 88 | it(`calls the afterAction and sets/returns the new state from it`, () => { 89 | builder.afterAction.returns(`new state`); 90 | expect(builder.reducer({}, {type: `SOME_ACTION`})).to.equal(`new state`); 91 | expect(builder.state).to.equal(`new state`); 92 | }); 93 | it(`calls the beforeAction/handler/afterAction in the correct order`, () => { 94 | builder.clone = () => ({ a: 0 }); 95 | builder.beforeAction.returns({ a: 1 }); 96 | builder.someAction.returns({ a: 2 }); 97 | builder.reducer({}, {type: `SOME_ACTION`}); 98 | 99 | expect(builder.beforeAction.calledWith({ a: 0 })); 100 | expect(builder.someAction.calledWith({ a: 1 })); 101 | expect(builder.afterAction.calledWith({ a: 2 })); 102 | }); 103 | }); 104 | }); 105 | }); 106 | describe(`#state`, () => { 107 | it(`returns the last previous state`, () => { 108 | builder._state = `foo`; 109 | expect(builder.state).to.equal(`foo`); 110 | }); 111 | }); 112 | }); 113 | describe(`StoreBuilder`, () => { 114 | let builder; 115 | let fakeCreateStore; 116 | let fakeComposedMiddleware; 117 | let fakeMiddleware1; 118 | let fakeMiddleware2; 119 | // Define a couple of fake ReducerBuilder classes to use in our tests 120 | class BarBuilder extends ReducerBuilder { 121 | stateName = `foo`; 122 | 123 | reducer() { 124 | return `foo reducer result` 125 | } 126 | } 127 | class FooBuilder extends ReducerBuilder { 128 | stateName = `bar`; 129 | 130 | reducer() { 131 | return `bar reducer result` 132 | } 133 | } 134 | beforeEach(() => { 135 | fakeCreateStore = sandbox.stub().returns(`fake store`); 136 | fakeComposedMiddleware = sandbox.stub().returns(fakeCreateStore); 137 | fakeMiddleware1 = sandbox.spy((createStore) => createStore); 138 | fakeMiddleware2 = sandbox.spy((createStore) => createStore); 139 | 140 | sandbox.stub(StoreBuilder.prototype, `combineReducers`).returns(`combined reducers`); 141 | sandbox.stub(StoreBuilder.prototype, `compose`).returns(fakeComposedMiddleware); 142 | builder = new StoreBuilder(fakeMiddleware1, FooBuilder, fakeMiddleware2, BarBuilder); 143 | }); 144 | describe(`#constructor`, () => { 145 | it(`can be instantiated`, () => { 146 | new StoreBuilder(); 147 | }); 148 | it(`stores all provided middleware`, () => { 149 | expect(builder._middleware).to.eql([fakeMiddleware1, fakeMiddleware2]); 150 | }); 151 | it(`composes all stored middleware in to a single function`, () => { 152 | expect(builder._composedMiddleware).to.eql(fakeComposedMiddleware); 153 | }); 154 | it(`stores all reducer builders (ie. all non-middleware arguments)`, () => { 155 | expect(builder._reducerBuilderClasses).to.eql([FooBuilder, BarBuilder]); 156 | }); 157 | it(`stores instances of all of its reducer builders`, () => { 158 | expect(builder._reducerBuilders[0]).to.be.an.instanceof(FooBuilder); 159 | expect(builder._reducerBuilders[1]).to.be.an.instanceof(BarBuilder); 160 | }); 161 | it(`stores all of its reducer builders' reducers by their state name`, () => { 162 | expect(builder._reducers.foo()).to.equal(`foo reducer result`); 163 | expect(builder._reducers.bar()).to.equal(`bar reducer result`); 164 | }); 165 | it(`stores the combination of all of its reducer builders' reducers`, () => { 166 | expect(builder.combineReducers.args[0][0]).to.equal(builder._reducers); 167 | expect(builder._combinedReducers).to.equal(`combined reducers`) 168 | }); 169 | }); 170 | describe(`#combineReducers`, () => { 171 | // NOTE: combineReducers is just a Redux alias, so all we need is a basic smoke test 172 | it(`can be called without throwing an exception`, () => { 173 | builder.combineReducers(); 174 | }); 175 | }); 176 | describe(`#compose`, () => { 177 | // NOTE: compose is just a Redux alias, so all we need for it is a basic smoke test 178 | it(`can be called without throwing an exception`, () => { 179 | builder.compose(); 180 | }); 181 | }); 182 | describe(`get createStore`, () => { 183 | describe(`when a createStore function has already been created`, () => { 184 | it(`returns the previously created createStore`, () => { 185 | builder._createStore = `create store`; 186 | expect(builder.createStore).to.equal(`create store`); 187 | }); 188 | }); 189 | describe(`when a createStore function has not yet been composed`, () => { 190 | it(`composes a new createStore using the provided middleware`, () => { 191 | delete builder._createStore; 192 | expect(builder.store).to.equal(`fake store`); 193 | expect(builder.compose.args[0]).to.eql([fakeMiddleware1, fakeMiddleware2]); 194 | }); 195 | }); 196 | }); 197 | describe(`get store`, () => { 198 | describe(`when a store has already been built`, () => { 199 | it(`returns the previously built store`, () => { 200 | builder._store = `fake store`; 201 | expect(builder.store).to.equal(`fake store`); 202 | }); 203 | }); 204 | describe(`when a store hasn't already been built`, () => { 205 | it(`builds a new store from its reducers and returns it`, () => { 206 | delete builder._store; 207 | builder._createStore = sandbox.stub().returns(`fake store`); 208 | 209 | builder.store; 210 | expect(builder._createStore.args[0][0]).to.equal(builder._combinedReducers); 211 | expect(builder.store).to.equal(`fake store`); 212 | }); 213 | }); 214 | }); 215 | }); 216 | }); 217 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Classy-Redux 2 | A "Classier" system of creating Redux reducer functions 3 | 4 | ## Basic Example 5 | 6 | Classy Redux let's you convert this (from the Redux To Do example): 7 | 8 | const todo = (state = {}, action) => { 9 | switch (action.type) { 10 | case 'ADD_TODO': 11 | return { 12 | id: action.id, 13 | text: action.text, 14 | completed: false 15 | } 16 | case 'TOGGLE_TODO': 17 | if (state.id !== action.id) { 18 | return state 19 | } 20 | 21 | return Object.assign({}, state, { 22 | completed: !state.completed 23 | }) 24 | 25 | default: 26 | return state 27 | } 28 | } 29 | const todos = (state = [], action) => { 30 | switch (action.type) { 31 | case 'ADD_TODO': 32 | return [ 33 | ...state, 34 | todo(undefined, action) 35 | ] 36 | case 'TOGGLE_TODO': 37 | return state.map(t => 38 | todo(t, action) 39 | ) 40 | default: 41 | return state 42 | } 43 | } 44 | 45 | Into this: 46 | 47 | class TodosBuilder extends ReducerBuilder { 48 | addTodo(action}, state) { 49 | state.push(this._buildTodo(action); 50 | } 51 | _buildTodo({id, text}) { 52 | return {completed: false, id, text}; 53 | } 54 | _toggleCompletion(id, todo) { 55 | if (todo.id === id) { 56 | todo.completed = !todo.completed; 57 | } 58 | return todo; 59 | } 60 | toggleTodo({id}, state) { 61 | return state.map(t => this._toggleCompletion(id, t)); 62 | } 63 | } 64 | const todoReducer = new TodoBuilder().reducer; 65 | 66 | ### How do I use it? 67 | 68 | // Step 0: Install it: 69 | 70 | npm install --save classy-redux 71 | 72 | // Step 1: Import it 73 | 74 | import {ReducerBuilder} from 'classy-redux'; 75 | 76 | // Step 2: Write your action creators as normal 77 | 78 | let nextTodoId = 0 79 | export const addTodo = (text) => ({type: 'ADD_TODO', id: nextTodoId++, text}); 80 | export const toggleTodo = (id) => {type: 'TOGGLE_TODO', id}); 81 | 82 | // Step 3: Extend ReducerBuilder 83 | 84 | class TodoReducerBuilder extends ReducerBuilder { 85 | 86 | // Step 4: Add action handlers with names matching the action type 87 | // (action handlers are passed the action and a copy of the state) 88 | 89 | addTodo(action, state) { 90 | state.push(this._buildTodo(action); 91 | // If you modify the state the action handler doesn't need to return anything; 92 | // that state will be returned 93 | } 94 | // (Optional) Use non-action handling helper methods 95 | _buildTodo({id, text}) { 96 | return {completed: false, id, text}; 97 | } 98 | 99 | toggleTodo(action, state) { 100 | // If an action handler return a ("truthy") value that value becomes the new state 101 | return state.map((id, todo) { 102 | if (todo.id === action.id) { 103 | todo.completed = !todo.completed; 104 | } 105 | return todo; 106 | }); 107 | } 108 | } 109 | 110 | // Step 5: Instantiate your ReducerBuilder to access your new reducer function 111 | 112 | export default new TodoReducerBuilder().reducer; 113 | 114 | // Optional 115 | 116 | class TodoReducerBuilder extends ReducerBuilder { 117 | 118 | // Optional Step #1: Use "reduction" to share state between methods: 119 | // (State only lives for one reducer cycle) 120 | 121 | addTodo(action, state) { 122 | this.reduction.currentAction = action; 123 | state.push(this._buildTodo(); 124 | } 125 | _buildTodo() { 126 | const {id, text} = this.reduction.currentAction; 127 | return {completed: false, id, text}; 128 | } 129 | 130 | // Optional Step #2: Use BeforeAction/AfterAction to trigger logic before/after the action handler: 131 | 132 | beforeAction(action, state) { 133 | this.reduction.todoInProgress = {}; 134 | this.reduction.isUrgent = false; 135 | state.push(this.reduction.todoInProgress); 136 | } 137 | addTodo(action, state) { 138 | // this.reduction.todoInProgress wass created in beforeAction 139 | this.reduction.todoInProgress.completed = false; 140 | this.reduction.todoInProgress.id = action.id; 141 | this.reduction.todoInProgress.text = action.text; 142 | } 143 | addUrgentTodo(action, state) { 144 | // This will get used by the afterAction 145 | this.reduction.isUrgent = true; 146 | this.addTodo(action, state); 147 | } 148 | afterAction(action, state) { 149 | this.reduction.todoInProgress.isUrgent = this.reduction.isUrgent; 150 | 151 | // Make state immutable before returning it 152 | // (As with action handlers, values returned from beforeAction/afterAction become the new state) 153 | return Immutable.Map(state); 154 | } 155 | 156 | // Optional Step 3: Override build to decorate the reducer 157 | 158 | build() { 159 | super.build(); 160 | // Decorate reducer using Redux Undo 161 | this.reducer = undoable(this.reducer, {debug: false, filter: distinctState()}); 162 | } 163 | 164 | 165 | 166 | ### If there's no `switch`/`case`, how does it know how to handle actions? 167 | 168 | When a `ResourceBuilder.reducer` receives an action it uses the action's type to find a matching *action handler*. To find the handler the `ReducerBuilder` simply looks for a method with the same name as the action type, converted to camel case. For instance, an action`{type: 'ADD_FOO_BAR'}` would be handled by the `ReducerBuilder`'s `addFooBar` method. 169 | 170 | If no corresponding method can be found for a provided action, Classy Redux throws an error. 171 | 172 | ### Why does addTodo have no `return` and looks like it's mutating state? 173 | 174 | Before Classy Redux passes the action to its handler it ensures that the old state doesn't get modified by calling its `clone` method to generate a new version of the state from the previous one. By default the `clone` method is just `(oldState) => _.cloneDeep(oldState)`, but you can override it to use a different clone algorithim (or none at all, if you would prefer to create a new state separately in each action handler). 175 | 176 | Once it has cloned the previous state the new cloned state is passed (along with the action) to the action handler. If the action handler returns a "truthy" value, that value will become the new state. If the handler *doesn't* return a value, the `reducer` will instead return the cloned state object. 177 | 178 | This allows action handlers to simply modify the provided state object in-place, without returning anything, and their changes will still be applied: 179 | 180 | addBar(action, state) { 181 | state.bars.push(bar); 182 | } 183 | 184 | ### What about immutability or pre/post-processing? 185 | 186 | Classy Redux provides two methods that you can override to add logic before or after every action handler: `afterAction` and `beforeAction`. These methods work just like an action handler, in that they are passed `action` and `state` arguments, and any truthy value they return will be used as the new state. 187 | 188 | If you wish to use an immutability library you can override the `afterAction` method to apply an immutability function to the state after its action handler finishes: 189 | 190 | afterEach(action, state) { 191 | return Immutable.Map(state); 192 | } 193 | 194 | ### What about reducer decorators (eg. Redux Undo)? 195 | 196 | The `ResourceBuilder` `build` method is called in the class's `constructor`, and by default all it does is bind the `reducer` function to the `ResourceBuilder` instance. However, you can override this method to add logic for "decorating" your reducer: 197 | 198 | build() { 199 | super.build(); 200 | this.reducer = undoable(this.reducer, {debug: false, filter: distinctState()}); 201 | } 202 | 203 | ### What if I want to share variables between `beforeAction`/`afterAction` and the action handler? 204 | 205 | Because the reducer is bound to its `ReducerBuilder`, you *could* define properties in one method and use them in another: 206 | 207 | beforeEach() { 208 | this._count = 0; 209 | } 210 | _doSomethingRepeatedly(things) { 211 | things.forEach(() => this._count += 1); 212 | } 213 | someAction(action, state) { 214 | this._doSomethingRepeatedly(action.things); 215 | return this._count; 216 | } 217 | 218 | However, the problem with doing that is that you might accidentally leave a property value from one call and have it affect another, later call. To avoid this Classy Redux recommends not defining properties directly on the `ReducerBuilder` instance, but instead on its `reduction` property. This property is automatically reset before every action is handled, so you never have to worry about "cleaning it up": 219 | 220 | beforeEach() { 221 | this.reduction.count = 0; 222 | } 223 | _doSomethingRepeatedly(things) { 224 | things.forEach(() => this.reduction.count += 1); 225 | } 226 | someAction(action, state) { 227 | this._doSomethingRepeatedly(action.things); 228 | return this.reduction.count; 229 | } 230 | 231 | ## StoreBuilder 232 | 233 | Reducers created from a `ReducerBuilder` can be used directly with Redux's `createStore`: 234 | 235 | const reducer = new YourReducerBuilder().reducer; 236 | const store = createStore(reducer); 237 | 238 | However Classy Redux also offers an optional StoreBuilder that lets you easily combine as many resource builders and middleware as you want, in any order: 239 | 240 | const {store} = new StoreBuilder(fooBuilder, thunk, barBuilder, bazBuilder, window.devToolsExtension); 241 | 242 | To use the `StoreBuilder` you must define a `stateName` property for each `ReducerBuilder` to serve as the key for the reducer when it is passed to `combineReducers`. This can be set as follows: 243 | 244 | // With the babel transformation "transform-class-properties" 245 | class FooResourceBuilder extends ResourceBuilder { 246 | stateName = 'foo'; 247 | } 248 | 249 | // Without "transform-class-properties" 250 | class FooResourceBuilder extends ResourceBuilder {} 251 | FooResourceBuilder.prototype.stateName = 'foo'; 252 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports.StoreBuilder = exports.ReducerBuilder = undefined; 7 | 8 | var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); 9 | 10 | var _redux = require('redux'); 11 | 12 | var _lodash = require('lodash'); 13 | 14 | function _toConsumableArray(arr) { if (Array.isArray(arr)) { for (var i = 0, arr2 = Array(arr.length); i < arr.length; i++) { arr2[i] = arr[i]; } return arr2; } else { return Array.from(arr); } } 15 | 16 | function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } 17 | 18 | var ReducerBuilder = exports.ReducerBuilder = function () { 19 | function ReducerBuilder() { 20 | _classCallCheck(this, ReducerBuilder); 21 | 22 | this.build(); 23 | } 24 | 25 | /** 26 | * Provides the initial state of the reducer. This initial state can either be a static object or 27 | * a getter method which returns the initial state (like the default implementation). 28 | */ 29 | 30 | 31 | _createClass(ReducerBuilder, [{ 32 | key: 'afterAction', 33 | 34 | 35 | /** 36 | * This method (which by default is a no-op) can be overriden to add any sort of 37 | * "post-processing" after an action handler has been applied 38 | */ 39 | value: function afterAction(action, newState) {} 40 | 41 | /** 42 | * This method (which by default is a no-op) can be overriden to add any sort of 43 | * "pre-processing" before an action handler has been applied 44 | */ 45 | 46 | }, { 47 | key: 'beforeAction', 48 | value: function beforeAction(action, newState) {} 49 | 50 | /** 51 | * Binds the reducer function to the ReducerBuilder. Can be overriden to apply other reducer 52 | * wrapping logic (eg. Redux-Undo) 53 | */ 54 | 55 | }, { 56 | key: 'build', 57 | value: function build() { 58 | this.reducer = this.reducer.bind(this); 59 | } 60 | 61 | /** 62 | * Returns a clone of the provided state 63 | */ 64 | 65 | }, { 66 | key: 'clone', 67 | value: function clone(oldState) { 68 | return (0, _lodash.cloneDeep)(oldState); 69 | } 70 | 71 | /** 72 | * Returns the matching method for the provided action type, if any. 73 | * "Matching" in this case means the method with a name that is the same as 74 | * the provided action type, except camel-cased instead of underscored. 75 | * 76 | * NOTE: Redux actions look like "@@redux/INIT" but clearly neither "@@" nor 77 | * "/" can be converted to camel-case (ie. we can't have a 78 | * "@@redux/Init" method). For this reason, handlers for Redux actions 79 | * should leave out both the "@@" and the "/". For instance, the 80 | * handler for the redux init action should be "reduxInit". 81 | * @param {string} actionType - an string describing an action, in Redux 82 | * standard (ie. capitalized and underscored) format 83 | * @returns {function} - the appropriate handler for the provided action 84 | * type, if any 85 | */ 86 | 87 | }, { 88 | key: '_getHandler', 89 | value: function _getHandler(actionType) { 90 | if (actionType.indexOf('@@') === 0) { 91 | // There's no way to convert @@ to camel case, so discard it (and ditto for "/") 92 | actionType = actionType.substr(2).replace('/'); 93 | } 94 | return this[(0, _lodash.camelCase)(actionType)]; 95 | } 96 | 97 | /** 98 | * Determines whether the provided action is one generated by Redux itself 99 | */ 100 | 101 | }, { 102 | key: '_isReduxInitAction', 103 | value: function _isReduxInitAction(_ref) { 104 | var type = _ref.type; 105 | 106 | return type.startsWith('@@redux'); 107 | } 108 | 109 | /** 110 | * The default reducer for all reducerBuilders. It: 111 | * A) generates a new state as a deep clone of the current state 112 | * B) finds the appropriate handling method for the provided action 113 | * C) invokes it (unless it was a non-Redux action and no handler was found 114 | * in which case it throws an eror) 115 | * 116 | * NOTE: Obviously cloning everything on every action is a bit overkill. If 117 | * this ever becomes a performance problem this method can simply be 118 | * overwritten to use a more performant method of creating the new 119 | * state. 120 | * 121 | * 122 | * @param oldState - the current state 123 | * @param action - an object with a type property that desscribes the action 124 | * @returns {array|object} - the new state 125 | */ 126 | 127 | }, { 128 | key: 'reducer', 129 | value: function reducer(oldState, action) { 130 | var newState = this.clone((0, _lodash.isUndefined)(oldState) ? this.initialState : oldState); 131 | if (this._isReduxInitAction(action)) return newState; 132 | 133 | var handler = this._getHandler(action.type); 134 | if (!handler) return newState; 135 | 136 | // Create a variable to hold data that will only live for a single reducer cycle 137 | // TODO: Change "reduction" to "_"? this._ = {}; is shorter/easier 138 | this.reduction = {}; 139 | 140 | newState = this.beforeAction(action, newState) || newState; 141 | newState = handler.call(this, action, newState) || newState; 142 | newState = this.afterAction(action, newState) || newState; 143 | return this._state = newState; 144 | } 145 | 146 | /** 147 | * Of course the actual state is stored in Redux, not in this class, but we keep a copy of 148 | * the last state given to Redux as a convenience, which can be accessed through this getter. 149 | */ 150 | 151 | }, { 152 | key: 'initialState', 153 | get: function get() { 154 | return {}; 155 | } 156 | }, { 157 | key: 'state', 158 | get: function get() { 159 | return this._state; 160 | } 161 | }]); 162 | 163 | return ReducerBuilder; 164 | }(); 165 | 166 | var isReducer = function isReducer(possible) { 167 | return possible.prototype instanceof ReducerBuilder; 168 | }; 169 | var isNotReducer = function isNotReducer(possible) { 170 | return !isReducer(possible); 171 | }; 172 | 173 | /** 174 | * Simple convenience class for creating a store from ReducerBuilder classes instead of 175 | * reducer functions. 176 | * 177 | * @param {ReducerBuilder[]|function[]} reducerBuildersAndMiddleware - 1+ Reducer builders and 0+ 178 | * middleware functions, all of which will be combined to create the store 179 | * 180 | * @example 181 | * const storeBuilder = new StoreBuilder(builder1, middleware1, builder2, middleware2, ...); 182 | * const reduxStore = storeBuilder.store; 183 | */ 184 | 185 | var StoreBuilder = exports.StoreBuilder = function () { 186 | function StoreBuilder() { 187 | _classCallCheck(this, StoreBuilder); 188 | 189 | for (var _len = arguments.length, reducerBuildersAndMiddleware = Array(_len), _key = 0; _key < _len; _key++) { 190 | reducerBuildersAndMiddleware[_key] = arguments[_key]; 191 | } 192 | 193 | this._middleware = reducerBuildersAndMiddleware.filter(isNotReducer); 194 | this._buildMiddleware(); 195 | 196 | this._reducerBuilderClasses = reducerBuildersAndMiddleware.filter(isReducer); 197 | this._buildReducers(); 198 | } 199 | 200 | _createClass(StoreBuilder, [{ 201 | key: '_buildMiddleware', 202 | value: function _buildMiddleware(reducerBuildersAndMiddleware) { 203 | this._composedMiddleware = this.compose.apply(this, _toConsumableArray(this._middleware)); 204 | } 205 | }, { 206 | key: '_buildReducers', 207 | value: function _buildReducers() { 208 | // NOTE: We don't *need* to be storing all of these as properties of the store builder ... 209 | // ... but it makes testing a whole lot easier (vs. using local variables) 210 | this._reducerBuilders = this._reducerBuilderClasses.map(function (Builder) { 211 | return new Builder(); 212 | }); 213 | this._reducers = this._reducerBuilders.reduce(function (reducers, reducerBuilder) { 214 | if (!reducerBuilder.stateName) throw new Error('Every reducer builder must have a ' + 'stateName to serve as its key in ' + 'the store state'); 215 | reducers[reducerBuilder.stateName] = reducerBuilder.reducer; 216 | return reducers; 217 | }, {}); 218 | this._combinedReducers = this.combineReducers(this._reducers); 219 | } 220 | /** 221 | * Simple alias for Redux's combineReducers (that can more easily be stubbed when testing). 222 | * Hypothetically if someone wanted to use a custom "combineReducers" function, this would be 223 | * the place to do it. 224 | */ 225 | 226 | }, { 227 | key: 'combineReducers', 228 | value: function combineReducers() { 229 | return _redux.combineReducers.apply(undefined, arguments); 230 | } 231 | /** 232 | * Simple alias for Redux's compose (that can more easily be stubbed when testing). 233 | * Hypothetically if someone wanted to use a custom "compose" function, this would be the place 234 | * to do it. 235 | */ 236 | 237 | }, { 238 | key: 'compose', 239 | value: function compose() { 240 | return _redux.compose.apply(undefined, arguments); 241 | } 242 | /** 243 | * Returns a version of createStore that has been "enhanced" with the provided middleware. 244 | */ 245 | 246 | }, { 247 | key: 'createStore', 248 | get: function get() { 249 | return this._createStore = this._createStore || this._composedMiddleware(_redux.createStore); 250 | } 251 | }, { 252 | key: 'store', 253 | get: function get() { 254 | return this._store = this._store || this.createStore(this._combinedReducers); 255 | } 256 | }]); 257 | 258 | return StoreBuilder; 259 | }(); 260 | --------------------------------------------------------------------------------