├── .babelrc ├── .eslintrc ├── .gitignore ├── .travis.yml ├── LICENSE.md ├── README.md ├── example ├── .babelrc ├── README.md ├── index.html ├── package.json ├── src │ ├── GifViewer.js │ ├── main.js │ └── reducer.js ├── test │ └── reducer.test.js └── webpack.config.js ├── package.json ├── src ├── combineReducers.js ├── createEffectCapableStore.js ├── enhanceReducer.js ├── index.js ├── sideEffect.js └── utils.js └── test ├── combineReducers.test.js ├── createEffectCapableStore.test.js ├── enhanceReducer.test.js ├── sideEffect.test.js └── utils.test.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-2"], 3 | "plugins": ["transform-runtime"] 4 | } -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", // https://github.com/babel/babel-eslint 3 | "experimental": true, 4 | "env": { // http://eslint.org/docs/user-guide/configuring.html#specifying-environments 5 | "browser": true, // browser global variables 6 | "node": true, // Node.js global variables and Node.js-specific rules 7 | "mocha": true 8 | }, 9 | "ecmaFeatures": { 10 | "arrowFunctions": true, 11 | "blockBindings": true, 12 | "classes": true, 13 | "defaultParams": true, 14 | "destructuring": true, 15 | "forOf": true, 16 | "generators": false, 17 | "modules": true, 18 | "objectLiteralComputedProperties": true, 19 | "objectLiteralDuplicateProperties": false, 20 | "objectLiteralShorthandMethods": true, 21 | "objectLiteralShorthandProperties": true, 22 | "spread": true, 23 | "superInFunctions": true, 24 | "templateStrings": true 25 | }, 26 | "rules": { 27 | /** 28 | * Strict mode 29 | */ 30 | // babel inserts "use strict"; for us 31 | "strict": [2, "never"], // http://eslint.org/docs/rules/strict 32 | 33 | /** 34 | * ES6 35 | */ 36 | "no-var": 2, // http://eslint.org/docs/rules/no-var 37 | "prefer-const": 2, // http://eslint.org/docs/rules/prefer-const 38 | 39 | /** 40 | * Variables 41 | */ 42 | "no-shadow": 2, // http://eslint.org/docs/rules/no-shadow 43 | "no-shadow-restricted-names": 2, // http://eslint.org/docs/rules/no-shadow-restricted-names 44 | "no-unused-vars": [2, { // http://eslint.org/docs/rules/no-unused-vars 45 | "vars": "local", 46 | "args": "after-used" 47 | }], 48 | "no-use-before-define": 2, // http://eslint.org/docs/rules/no-use-before-define 49 | 50 | /** 51 | * Possible errors 52 | */ 53 | "comma-dangle": [2, "never"], // http://eslint.org/docs/rules/comma-dangle 54 | "no-cond-assign": [2, "always"], // http://eslint.org/docs/rules/no-cond-assign 55 | "no-console": 1, // http://eslint.org/docs/rules/no-console 56 | "no-debugger": 1, // http://eslint.org/docs/rules/no-debugger 57 | "no-alert": 1, // http://eslint.org/docs/rules/no-alert 58 | "no-constant-condition": 1, // http://eslint.org/docs/rules/no-constant-condition 59 | "no-dupe-keys": 2, // http://eslint.org/docs/rules/no-dupe-keys 60 | "no-duplicate-case": 2, // http://eslint.org/docs/rules/no-duplicate-case 61 | "no-empty": 2, // http://eslint.org/docs/rules/no-empty 62 | "no-ex-assign": 2, // http://eslint.org/docs/rules/no-ex-assign 63 | "no-extra-boolean-cast": 0, // http://eslint.org/docs/rules/no-extra-boolean-cast 64 | "no-extra-semi": 2, // http://eslint.org/docs/rules/no-extra-semi 65 | "no-func-assign": 2, // http://eslint.org/docs/rules/no-func-assign 66 | "no-inner-declarations": 2, // http://eslint.org/docs/rules/no-inner-declarations 67 | "no-invalid-regexp": 2, // http://eslint.org/docs/rules/no-invalid-regexp 68 | "no-irregular-whitespace": 2, // http://eslint.org/docs/rules/no-irregular-whitespace 69 | "no-obj-calls": 2, // http://eslint.org/docs/rules/no-obj-calls 70 | "no-sparse-arrays": 2, // http://eslint.org/docs/rules/no-sparse-arrays 71 | "no-unreachable": 2, // http://eslint.org/docs/rules/no-unreachable 72 | "use-isnan": 2, // http://eslint.org/docs/rules/use-isnan 73 | "block-scoped-var": 2, // http://eslint.org/docs/rules/block-scoped-var 74 | 75 | /** 76 | * Best practices 77 | */ 78 | "consistent-return": 2, // http://eslint.org/docs/rules/consistent-return 79 | "curly": [2, "multi-line"], // http://eslint.org/docs/rules/curly 80 | "default-case": 2, // http://eslint.org/docs/rules/default-case 81 | "dot-notation": [2, { // http://eslint.org/docs/rules/dot-notation 82 | "allowKeywords": true 83 | }], 84 | "eqeqeq": 2, // http://eslint.org/docs/rules/eqeqeq 85 | "guard-for-in": 2, // http://eslint.org/docs/rules/guard-for-in 86 | "no-caller": 2, // http://eslint.org/docs/rules/no-caller 87 | "no-eq-null": 2, // http://eslint.org/docs/rules/no-eq-null 88 | "no-eval": 2, // http://eslint.org/docs/rules/no-eval 89 | "no-extend-native": 2, // http://eslint.org/docs/rules/no-extend-native 90 | "no-extra-bind": 2, // http://eslint.org/docs/rules/no-extra-bind 91 | "no-fallthrough": 2, // http://eslint.org/docs/rules/no-fallthrough 92 | "no-floating-decimal": 2, // http://eslint.org/docs/rules/no-floating-decimal 93 | "no-implied-eval": 2, // http://eslint.org/docs/rules/no-implied-eval 94 | "no-lone-blocks": 2, // http://eslint.org/docs/rules/no-lone-blocks 95 | "no-loop-func": 2, // http://eslint.org/docs/rules/no-loop-func 96 | "no-multi-str": 2, // http://eslint.org/docs/rules/no-multi-str 97 | "no-native-reassign": 2, // http://eslint.org/docs/rules/no-native-reassign 98 | "no-new": 2, // http://eslint.org/docs/rules/no-new 99 | "no-new-func": 2, // http://eslint.org/docs/rules/no-new-func 100 | "no-new-wrappers": 2, // http://eslint.org/docs/rules/no-new-wrappers 101 | "no-octal": 2, // http://eslint.org/docs/rules/no-octal 102 | "no-octal-escape": 2, // http://eslint.org/docs/rules/no-octal-escape 103 | "no-param-reassign": 2, // http://eslint.org/docs/rules/no-param-reassign 104 | "no-proto": 2, // http://eslint.org/docs/rules/no-proto 105 | "no-redeclare": 2, // http://eslint.org/docs/rules/no-redeclare 106 | "no-return-assign": 2, // http://eslint.org/docs/rules/no-return-assign 107 | "no-script-url": 2, // http://eslint.org/docs/rules/no-script-url 108 | "no-self-compare": 2, // http://eslint.org/docs/rules/no-self-compare 109 | "no-sequences": 2, // http://eslint.org/docs/rules/no-sequences 110 | "no-throw-literal": 2, // http://eslint.org/docs/rules/no-throw-literal 111 | "no-with": 2, // http://eslint.org/docs/rules/no-with 112 | "radix": 2, // http://eslint.org/docs/rules/radix 113 | "vars-on-top": 2, // http://eslint.org/docs/rules/vars-on-top 114 | "wrap-iife": [2, "any"], // http://eslint.org/docs/rules/wrap-iife 115 | "yoda": 2, // http://eslint.org/docs/rules/yoda 116 | 117 | /** 118 | * Style 119 | */ 120 | "indent": [2, 2], // http://eslint.org/docs/rules/indent 121 | "brace-style": [2, // http://eslint.org/docs/rules/brace-style 122 | "1tbs", { 123 | "allowSingleLine": true 124 | }], 125 | "quotes": [ 126 | 2, "single", "avoid-escape" // http://eslint.org/docs/rules/quotes 127 | ], 128 | "camelcase": [2, { // http://eslint.org/docs/rules/camelcase 129 | "properties": "never" 130 | }], 131 | "comma-spacing": [2, { // http://eslint.org/docs/rules/comma-spacing 132 | "before": false, 133 | "after": true 134 | }], 135 | "comma-style": [2, "last"], // http://eslint.org/docs/rules/comma-style 136 | "eol-last": 2, // http://eslint.org/docs/rules/eol-last 137 | "func-names": 1, // http://eslint.org/docs/rules/func-names 138 | "key-spacing": [2, { // http://eslint.org/docs/rules/key-spacing 139 | "beforeColon": false, 140 | "afterColon": true 141 | }], 142 | "new-cap": [2, { // http://eslint.org/docs/rules/new-cap 143 | "newIsCap": true 144 | }], 145 | "no-multiple-empty-lines": [2, { // http://eslint.org/docs/rules/no-multiple-empty-lines 146 | "max": 2 147 | }], 148 | "no-nested-ternary": 2, // http://eslint.org/docs/rules/no-nested-ternary 149 | "no-new-object": 2, // http://eslint.org/docs/rules/no-new-object 150 | "no-spaced-func": 2, // http://eslint.org/docs/rules/no-spaced-func 151 | "no-trailing-spaces": 2, // http://eslint.org/docs/rules/no-trailing-spaces 152 | "no-underscore-dangle": 0, // http://eslint.org/docs/rules/no-underscore-dangle 153 | "one-var": [2, "never"], // http://eslint.org/docs/rules/one-var 154 | "padded-blocks": [2, "never"], // http://eslint.org/docs/rules/padded-blocks 155 | "semi": [2, "always"], // http://eslint.org/docs/rules/semi 156 | "semi-spacing": [2, { // http://eslint.org/docs/rules/semi-spacing 157 | "before": false, 158 | "after": true 159 | }], 160 | "space-before-blocks": 2, // http://eslint.org/docs/rules/space-before-blocks 161 | "space-before-function-paren": [2, "never"], // http://eslint.org/docs/rules/space-before-function-paren 162 | "space-infix-ops": 2 // http://eslint.org/docs/rules/space-infix-ops 163 | } 164 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.log 3 | node_modules 4 | lib 5 | coverage -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "5" 4 | script: 5 | - npm run lint 6 | - npm test -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Salsita software 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | redux-side-effects 2 | ============= 3 | 4 | [![NPM version][npm-image]][npm-url] 5 | [![Dependencies][dependencies]][npm-url] 6 | [![Build status][travis-image]][travis-url] 7 | [![Downloads][downloads-image]][downloads-url] 8 | 9 | 10 | > What if your reducers were generators? You could yield side effects and return application state. 11 | 12 | Believe it or not, but side effects are still tied with your application's domain. Ideally, you would be able to keep them in reducers. But wait! Everybody is saying that reducers must be pure! So yeah, just keep the reducer pure and reduce side effects as well. 13 | 14 | ## Why? 15 | 16 | Some people (I am one of them) believe that [Elm](https://github.com/evancz/elm-architecture-tutorial/#example-5-random-gif-viewer) has found the proper way how to handle side effects. Yes, we have a solution for async code in [redux](https://github.com/rackt/redux) and it's [`redux-thunk`](https://github.com/gaearon/redux-thunk) but the solution has two major drawbacks: 17 | 18 | 1) Application logic is not in one place, which leads to the state where business domain may be encapsulated by service domain. 19 | 20 | 2) Unit testing of some use cases which heavy relies on side effect is nearly impossible. Yes, you can always test those things in isolation but then you will lose the context. It's breaking the logic apart, which is making it basically impossible to test as single unit. 21 | 22 | Therefore ideal solution is to keep the domain logic where it belongs (reducers) and abstract away execution of side effects. Which means that your reducers will still be pure (Yes! Also hot-reloadable and easily testable). There are basically two options, either we can abuse reducer's reduction (which is basically a form of I/O Monad) or we can simply put a bit more syntactic sugar on it. 23 | 24 | Because ES6 [generators](https://developer.mozilla.org/cs/docs/Web/JavaScript/Reference/Statements/function*) is an excellent way how to perform lazy evaluation, it's also a perfect tool for the syntax sugar to simplify working with side effects. 25 | 26 | Just imagine, you can `yield` a side effect and framework runtime is responsible for executing it after `reducer` reduces new application state. This ensures that Reducer remains pure. 27 | 28 | ```javascript 29 | import { sideEffect } from 'redux-side-effects'; 30 | 31 | const loggingEffect = (dispatch, message) => console.log(message); 32 | 33 | function* reducer(appState = 1, action) { 34 | yield sideEffect(loggingEffect, 'This is side effect'); 35 | 36 | return appState + 1; 37 | } 38 | ``` 39 | 40 | The function is technically pure because it does not execute any side effects and given the same arguments the result is still the same. 41 | 42 | ## Usage 43 | 44 | API of this library is fairly simple, the only possible functions are `createEffectCapableStore` and `sideEffect`. `createEffectCapableStore` is a store enhancer which enables us to use Reducers in form of Generators. `sideEffect` returns declarative Side effect and allows us easy testing. In order to use it in your application you need to import it, keep in mind that it's [named import](http://www.2ality.com/2014/09/es6-modules-final.html#named_exports_%28several_per_module%29) therefore following construct is correct: 45 | 46 | `import { createEffectCapableStore } from 'redux-side-effects'` 47 | 48 | The function is responsible for creating Redux store factory. It takes just one argument which is original Redux [`createStore`](http://redux.js.org/docs/api/createStore.html) function. Of course you can provide your own enhanced implementation of `createStore` factory. 49 | 50 | To create the store it's possible to do the following: 51 | 52 | ```javascript 53 | import { createStore } from 'redux'; 54 | import { createEffectCapableStore } from 'redux-side-effects'; 55 | 56 | const reducer = appState => appState; 57 | 58 | const storeFactory = createEffectCapableStore(createStore); 59 | const store = storeFactory(reducer); 60 | 61 | ``` 62 | 63 | Basically something like this should be fully functional: 64 | 65 | ```javascript 66 | import React from 'react'; 67 | import { render } from 'react-dom'; 68 | import { createStore } from 'redux'; 69 | import { createEffectCapableStore, sideEffect } from 'redux-side-effects'; 70 | 71 | import * as API from './API'; 72 | 73 | const storeFactory = createEffectCapableStore(createStore); 74 | 75 | const addTodoEffect = (dispatch, todo) => API.addTodo(todo).then(() => dispatch({type: 'TODO_ADDED'})); 76 | 77 | const store = storeFactory(function*(appState = {todos: [], loading: false}, action) { 78 | if (action.type === 'ADD_TODO') { 79 | yield sideEffect(addTodoEffect, action.payload); 80 | 81 | return {...appState, todos: [...appState.todos, action.payload], loading: true}; 82 | } else if (action.type === 'TODO_ADDED') { 83 | return {...appState, loading: false}; 84 | } else { 85 | return appState; 86 | } 87 | }); 88 | 89 | render(, document.getElementById('app-container')); 90 | 91 | ``` 92 | 93 | ## Declarative Side Effects 94 | 95 | The `sideEffect` function is just a very simple declarative way how to express any Side Effect. Basically you can only `yield` result of the function and the function must be called with at least one argument which is Side Effect execution implementation function, all the additional arguments will be passed as arguments to your Side Effect execution implementation function. 96 | 97 | ```javascript 98 | const effectImplementation = (dispatch, arg1, arg2, arg3) => { 99 | // Your Side Effect implementation 100 | }; 101 | 102 | 103 | yield sideEffect(effectImplementation, 'arg1', 'arg2', 'arg3'....); 104 | ``` 105 | 106 | Be aware that first argument provided to Side Effect implementation function is always `dispatch` so that you can `dispatch` new actions within Side Effect. 107 | 108 | ## Unit testing 109 | 110 | Unit Testing with `redux-side-effects` is a breeze. You just need to assert iterable which is result of Reducer. 111 | 112 | ```javascript 113 | function* reducer(appState) { 114 | if (appState === 42) { 115 | yield sideEffect(fooEffect, 'arg1'); 116 | 117 | return 1; 118 | } else { 119 | return 0; 120 | } 121 | } 122 | 123 | // Now we can effectively assert whether app state is correctly mutated and side effect is yielded. 124 | it('should yield fooEffect with arg1 when condition is met', () => { 125 | const iterable = reducer(42); 126 | 127 | assert.deepEqual(iterable.next(), { 128 | done: false, 129 | value: sideEffect(fooEffect, 'arg1') 130 | }); 131 | assert.equal(iterable.next(), { 132 | done: true, 133 | value: 1 134 | }); 135 | }) 136 | 137 | ``` 138 | 139 | 140 | ## Example 141 | 142 | There's very simple fully working example including unit tests inside `example` folder. 143 | 144 | You can check it out by: 145 | ``` 146 | cd example 147 | npm install 148 | npm start 149 | open http://localhost:3000 150 | ``` 151 | 152 | Or you can run tests by 153 | ``` 154 | cd example 155 | npm install 156 | npm test 157 | ``` 158 | 159 | ## Contribution 160 | 161 | In case you are interested in contribution, feel free to send a PR. Keep in mind that any created issue is much appreciated. For local development: 162 | 163 | ``` 164 | npm install 165 | npm run test:watch 166 | ``` 167 | 168 | You can also `npm link` the repo to your local Redux application so that it's possible to test the expected behaviour in real Redux application. 169 | 170 | Please for any PR, don't forget to write unit test. 171 | 172 | ## Need Help? 173 | 174 | You can reach me on [reactiflux](http://www.reactiflux.com) - username tomkis1, or DM me on [twitter](https://twitter.com/tomkisw). 175 | 176 | ## FAQ 177 | 178 | > Does redux-side-effects work with working Redux application? 179 | 180 | Yes! I set this as the major condition when started thinking about this library. Therefore the API is completely backwards compatible with any 181 | Redux application. 182 | 183 | > My middlewares are not working anymore, what has just happened? 184 | 185 | If you are using middlewares you have to use `createEffectCapableStore` for middleware enhanced store factory, not vice versa. Meaning: 186 | 187 | ```javascript 188 | const createStoreWithMiddleware = applyMiddleware(test)(createStore); 189 | const storeFactory = createEffectCapableStore(createStoreWithMiddleware); 190 | const store = storeFactory(reducer); 191 | ``` 192 | 193 | is correct. 194 | 195 | > Can I compose reducers? 196 | 197 | Yes! `yield*` can help you with the composition. The concept is explained in this [gist](https://gist.github.com/tomkis1/236f6ba182b48fde4dc9) 198 | 199 | > I keep getting warning "createEffectCapableStore enhancer from redux-side-effects package is used yet the provided root reducer is not a generator function", what does that mean? 200 | 201 | Keep in mind that your root reducer needs to be generator function therefore this will throw the warning: 202 | 203 | ```javascript 204 | const storeFactory = createEffectCapableStore(createStore); 205 | const store = storeFactory(function(appState) { return appState; }); 206 | ``` 207 | 208 | but this will work: 209 | 210 | ```javascript 211 | const storeFactory = createEffectCapableStore(createStore); 212 | const store = storeFactory(function* (appState) { return appState; }); 213 | ``` 214 | 215 | > Can I use ()* => {} instead of function*()? 216 | 217 | Unfortunately no. Only `function*` is valid ES6 syntax. 218 | 219 | > I am using combineReducers, how does this work with redux-side-effects? 220 | 221 | If you are using standard Redux [`combineReducer`](http://rackt.org/redux/docs/api/combineReducers.html) in your application, please use the imported version from this package, original implementation does not work with generators. However, keep in mind that this method is [opinionated](http://rackt.org/redux/docs/api/combineReducers.html#notes) and therefore you should probably provide your own implementation. 222 | 223 | Usage is simple: 224 | 225 | `import { combineReducers } from 'redux-side-effects'` 226 | 227 | 228 | [npm-image]: https://img.shields.io/npm/v/redux-side-effects.svg?style=flat-square 229 | [npm-url]: https://npmjs.org/package/redux-side-effects 230 | [travis-image]: https://img.shields.io/travis/salsita/redux-side-effects.svg?style=flat-square 231 | [travis-url]: https://travis-ci.org/salsita/redux-side-effects 232 | [downloads-image]: http://img.shields.io/npm/dm/redux-side-effects.svg?style=flat-square 233 | [downloads-url]: https://npmjs.org/package/redux-side-effects 234 | [dependencies]: https://david-dm.org/salsita/redux-side-effects.svg 235 | -------------------------------------------------------------------------------- /example/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-2", "react"] 3 | } -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # redux-side-effects-example 2 | 3 | Run example: 4 | 5 | ``` 6 | npm install 7 | npm start 8 | open http://localhost:3000 9 | ``` 10 | 11 | Run tests: 12 | 13 | ``` 14 | npm test 15 | ``` -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | redux-side-effects-example 6 | 7 | 8 |
9 | 10 | 11 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-side-effects-example", 3 | "version": "0.1.0", 4 | "scripts": { 5 | "start": "./node_modules/.bin/webpack-dev-server --config webpack.config.js --port 3000 --hot --content-base ./", 6 | "test": "./node_modules/.bin/mocha --require babel-core/register --recursive --require babel-polyfill", 7 | "test:watch": "npm test -- --watch" 8 | }, 9 | "devDependencies": { 10 | "babel-cli": "^6.5.1", 11 | "babel-core": "^6.5.2", 12 | "babel-eslint": "^4.1.8", 13 | "babel-loader": "^6.2.2", 14 | "babel-preset-es2015": "^6.5.0", 15 | "babel-preset-react": "^6.5.0", 16 | "babel-preset-stage-2": "^6.5.0", 17 | "chai": "^3.4.1", 18 | "mocha": "^2.2.5", 19 | "webpack": "^1.12.4", 20 | "webpack-dev-server": "^1.12.1" 21 | }, 22 | "dependencies": { 23 | "babel-runtime": "^6.5.0", 24 | "bluebird": "^3.3.4", 25 | "react": "^0.14.7", 26 | "react-dom": "^0.14.2", 27 | "react-redux": "^4.0.0", 28 | "react-router": "^2.0.0", 29 | "react-router-redux": "^4.0.0", 30 | "redux": "^3.3.1", 31 | "redux-elm": "^1.0.0-alpha", 32 | "redux-side-effects": "^1.0.0", 33 | "superagent": "^1.8.2", 34 | "superagent-bluebird-promise": "^3.0.0" 35 | }, 36 | "author": "Tomas Weiss ", 37 | "license": "MIT" 38 | } 39 | -------------------------------------------------------------------------------- /example/src/GifViewer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | export default connect(appState => appState)(({ gifUrl, topic, loading, dispatch }) => { 5 | if (!loading) { 6 | return ( 7 |
8 | {gifUrl && } 9 |
10 | Topic: dispatch({ type: 'CHANGE_TOPIC', payload: ev.target.value })} value={topic} />
11 | 12 |
13 | ); 14 | } else { 15 | return
Loading
; 16 | } 17 | }); 18 | -------------------------------------------------------------------------------- /example/src/main.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'react-dom'; 3 | import { createStore, compose } from 'redux'; 4 | import { Provider } from 'react-redux'; 5 | import { createEffectCapableStore } from 'redux-side-effects'; 6 | 7 | import reducer from './reducer'; 8 | import GifViewer from './GifViewer'; 9 | 10 | const storeFactory = compose( 11 | createEffectCapableStore, 12 | window.devToolsExtension ? window.devToolsExtension() : f => f 13 | )(createStore); 14 | 15 | const store = storeFactory(reducer); 16 | 17 | render(( 18 | 19 | 20 | 21 | ), document.getElementById('app')); -------------------------------------------------------------------------------- /example/src/reducer.js: -------------------------------------------------------------------------------- 1 | import request from 'superagent-bluebird-promise'; 2 | import { sideEffect } from 'redux-side-effects'; 3 | 4 | export const loadGifEffect = (dispatch, topic) => { 5 | request(`http://api.giphy.com/v1/gifs/random?api_key=dc6zaTOxFJmzC&tag=${topic}`) 6 | .then(response => dispatch({ type: 'NEW_GIF', payload: response.body.data.image_url })); 7 | }; 8 | 9 | const initialAppState = { 10 | gifUrl: null, 11 | loading: false, 12 | topic: 'funny cats' 13 | }; 14 | 15 | export default function* (appState = initialAppState, action) { 16 | switch (action.type) { 17 | case 'CHANGE_TOPIC': 18 | return { 19 | ...appState, 20 | topic: action.payload 21 | }; 22 | 23 | case 'LOAD_GIF': 24 | yield sideEffect(loadGifEffect, appState.topic); 25 | 26 | return { 27 | ...appState, 28 | loading: true 29 | }; 30 | 31 | case 'NEW_GIF': 32 | return { 33 | ...appState, 34 | loading: false, 35 | gifUrl: action.payload 36 | }; 37 | default: 38 | return appState; 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /example/test/reducer.test.js: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai'; 2 | import { sideEffect } from 'redux-side-effects'; 3 | 4 | import reducer, { loadGifEffect } from '../src/reducer'; 5 | 6 | describe('reducer test', () => { 7 | it('should set loading flag and yield side effect to load gif with specific topic after clicking the button', () => { 8 | const initialAppState = reducer(undefined, { type: 'init' }).next().value; 9 | const iterable = reducer(initialAppState, { type: 'LOAD_GIF' }); 10 | 11 | assert.deepEqual(iterable.next(), { 12 | done: false, 13 | value: sideEffect(loadGifEffect, 'funny cats') 14 | }); 15 | assert.deepEqual(iterable.next(), { 16 | done: true, 17 | value: { 18 | gifUrl: null, 19 | loading: true, 20 | topic: 'funny cats' 21 | } 22 | }); 23 | }); 24 | 25 | it('should reset the loading flag and set appropriate GIF url when GIF is loaded', () => { 26 | const iterable = reducer({ 27 | gifUrl: null, 28 | loading: true, 29 | topic: 'funny cats' 30 | }, { type: 'NEW_GIF', payload: 'newurl' }); 31 | 32 | assert.deepEqual(iterable.next(), { 33 | done: true, 34 | value: { 35 | gifUrl: 'newurl', 36 | loading: false, 37 | topic: 'funny cats' 38 | } 39 | }); 40 | }); 41 | }); -------------------------------------------------------------------------------- /example/webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var webpack = require('webpack'); 3 | 4 | module.exports = { 5 | debug: true, 6 | target: 'web', 7 | devtool: 'sourcemap', 8 | plugins: [ 9 | new webpack.NoErrorsPlugin() 10 | ], 11 | entry: [ 12 | 'babel-polyfill', 13 | 'webpack-dev-server/client?http://localhost:3000', 14 | 'webpack/hot/only-dev-server', 15 | './src/main.js' 16 | ], 17 | output: { 18 | path: path.join(__dirname, './dev'), 19 | filename: 'app.bundle.js' 20 | }, 21 | module: { 22 | loaders: [{ 23 | test: /\.jsx$|\.js$/, 24 | loaders: ['babel-loader'], 25 | include: path.join(__dirname, './src') 26 | }] 27 | }, 28 | resolve: { 29 | extensions: ['', '.js', '.jsx'] 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-side-effects", 3 | "version": "1.1.1", 4 | "description": "Redux toolset for keeping all the side effects inside your reducers while maintaining their purity.", 5 | "main": "lib/index.js", 6 | "files": [ 7 | "lib", 8 | "src" 9 | ], 10 | "jsnext:main": "src/index.js", 11 | "scripts": { 12 | "build:lib": "./node_modules/.bin/babel src --out-dir lib", 13 | "check": "npm run lint && npm run test", 14 | "lint": "./node_modules/.bin/eslint src/", 15 | "preversion": "npm run check", 16 | "version": "npm run build:lib", 17 | "postversion": "git push && git push --tags", 18 | "prepublish": "npm run build:lib", 19 | "test": "./node_modules/.bin/mocha --require babel-core/register --recursive", 20 | "test:cov": "./node_modules/.bin/babel-node $(npm bin)/isparta cover $(npm bin)/_mocha -- --recursive", 21 | "test:watch": "npm test -- --watch" 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "git+https://github.com/salsita/redux-side-effects.git" 26 | }, 27 | "keywords": [ 28 | "redux", 29 | "side", 30 | "effects", 31 | "reducing", 32 | "impurities", 33 | "hot", 34 | "reload", 35 | "elm" 36 | ], 37 | "author": "Tomas Weiss ", 38 | "license": "MIT", 39 | "bugs": { 40 | "url": "https://github.com/salsita/redux-side-effects/issues" 41 | }, 42 | "engines": { 43 | "node": ">=5.0.0", 44 | "npm": ">=3.0.0" 45 | }, 46 | "homepage": "https://github.com/salsita/redux-side-effects#readme", 47 | "devDependencies": { 48 | "babel": "^6.3.26", 49 | "babel-cli": "^6.7.5", 50 | "babel-core": "^6.7.6", 51 | "babel-eslint": "^6.0.2", 52 | "babel-plugin-transform-runtime": "^6.7.5", 53 | "babel-preset-es2015": "^6.3.13", 54 | "babel-preset-stage-0": "^6.3.13", 55 | "babel-runtime": "^6.3.19", 56 | "chai": "^3.4.1", 57 | "eslint": "^2.8.0", 58 | "estraverse-fb": "^1.3.1", 59 | "isparta": "^4.0.0", 60 | "mocha": "^2.2.5", 61 | "redux": "^3.4.0", 62 | "sinon": "^1.17.2" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/combineReducers.js: -------------------------------------------------------------------------------- 1 | import { mapObject, generatorMapObject, isGenerator } from './utils'; 2 | 3 | /** 4 | * Port of redux [`combineReducers`](http://rackt.org/redux/docs/api/combineReducers.html). The difference is though 5 | * that it's allowed to provide reducers as generators. Please, be aware that the function does not contain any 6 | * special sanity checks. The reason why is that this is quite specific implementation which should most likely 7 | * be in user land. 8 | * 9 | * @param {Map} Map of reducers, key will be used as key in the application state 10 | * @returns {Function} Single reducer 11 | */ 12 | export default reducers => { 13 | const initialState = mapObject(reducers, () => undefined); 14 | 15 | return function*(state = initialState, action) { 16 | let hasChanged; 17 | 18 | const mutatedReduction = yield* generatorMapObject(reducers, function*(reducer, key) { 19 | const previousKeyedReduction = state[key]; 20 | 21 | let nextKeyedReduction; 22 | if (isGenerator(reducer)) { 23 | nextKeyedReduction = yield* reducer(previousKeyedReduction, action); 24 | } else { 25 | nextKeyedReduction = reducer(previousKeyedReduction, action); 26 | } 27 | 28 | hasChanged = hasChanged || nextKeyedReduction !== previousKeyedReduction; 29 | return nextKeyedReduction; 30 | }); 31 | 32 | return hasChanged ? mutatedReduction : state; 33 | }; 34 | }; 35 | -------------------------------------------------------------------------------- /src/createEffectCapableStore.js: -------------------------------------------------------------------------------- 1 | import enhanceReducer from './enhanceReducer'; 2 | 3 | /** 4 | * Creates enhanced store factory, which takes original `createStore` as argument. 5 | * Returns modified store which is capable of handling Reducers in form of Generators. 6 | * 7 | * @param {Function} Original createStore implementation to be enhanced 8 | * @returns {Function} Store factory 9 | */ 10 | export default createStore => (rootReducer, initialState) => { 11 | let store = null; 12 | let executeEffects = false; // Flag to indicate whether it's allowed to execute effects 13 | let wrappedDispatch = null; 14 | 15 | const callWithEffects = fn => { 16 | executeEffects = true; 17 | const result = fn(); 18 | executeEffects = false; 19 | return result; 20 | }; 21 | 22 | const deferEffects = effects => { 23 | if (executeEffects) { 24 | setTimeout(() => { 25 | if (wrappedDispatch) { 26 | effects.forEach(([fn, ...args]) => fn(wrappedDispatch, ...args)); 27 | } else { 28 | // In case anyone tries to do some magic with `setTimeout` while creating store 29 | console.warn('There\'s been attempt to execute effects yet proper creating of store has not been finished yet'); 30 | } 31 | }, 0); 32 | } 33 | }; 34 | 35 | // createStore dispatches @@INIT action and want this action to be dispatched 36 | // with effects too 37 | callWithEffects(() => { 38 | store = createStore(enhanceReducer(rootReducer, deferEffects), initialState); 39 | }); 40 | 41 | // only wrapped dispatch executes effects 42 | // that ensures that for example devtools do not execute effects while replaying 43 | wrappedDispatch = (...args) => callWithEffects(() => store.dispatch(...args)); 44 | 45 | return { 46 | ...store, 47 | dispatch: wrappedDispatch, 48 | replaceReducer: nextReducer => { 49 | store.replaceReducer(enhanceReducer(nextReducer, deferEffects)); 50 | } 51 | }; 52 | }; 53 | -------------------------------------------------------------------------------- /src/enhanceReducer.js: -------------------------------------------------------------------------------- 1 | import { 2 | isFunction, 3 | isUndefined, 4 | isIterable, 5 | invariant, 6 | mapIterable 7 | } from './utils'; 8 | 9 | import { 10 | isSideEffect 11 | } from './sideEffect'; 12 | 13 | /** 14 | * Iterator mapper, maps iterator to value 15 | * 16 | * @param {Object} Iterator 17 | * @returns {Any} Iterator Value 18 | */ 19 | const mapValue = iterator => iterator.value; 20 | 21 | /** 22 | * Reducer enhancer. Iterates over generator like reducer reduction and accumulates 23 | * all the side effects and reduced application state. 24 | * 25 | * @param {Function} Root reducer in form of generator function 26 | * @param {Function} Function used for deferring effects accumulated from Reduction 27 | * @returns {Function} Reducer 28 | */ 29 | export default (rootReducer, deferEffects) => (appState, action) => { 30 | invariant(isFunction(rootReducer), 31 | 'Provided root reducer is not a function.'); 32 | 33 | // Calling the Root reducer should return an Iterable 34 | const reduction = rootReducer(appState, action); 35 | 36 | if (isIterable(reduction)) { 37 | // Iterable returned by Root reducer is mapped into array of values. 38 | // Last value in the array is reduced application state all the other values 39 | // are just side effects. 40 | const effects = mapIterable(reduction, mapValue); 41 | const newState = effects.pop(); 42 | 43 | invariant(effects.every(isSideEffect), 44 | 'Yielded side effects must always be created using sideEffect function'); 45 | 46 | deferEffects(effects); 47 | 48 | invariant(!isUndefined(newState), 49 | 'Root reducer does not return new application state. Undefined is returned'); 50 | 51 | return newState; 52 | } else { 53 | console.warn( 54 | 'createEffectCapableStore enhancer from redux-side-effects package is used, ' + 55 | 'yet the provided root reducer is not a generator function' 56 | ); 57 | 58 | return reduction; 59 | } 60 | }; 61 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import createEffectCapableStore from './createEffectCapableStore'; 2 | import combineReducers from './combineReducers'; 3 | import sideEffect from './sideEffect'; 4 | 5 | export { 6 | createEffectCapableStore, 7 | combineReducers, 8 | sideEffect 9 | }; 10 | -------------------------------------------------------------------------------- /src/sideEffect.js: -------------------------------------------------------------------------------- 1 | import { 2 | isFunction, 3 | invariant 4 | } from './utils'; 5 | 6 | /** 7 | * Creates array describing Side Effect, where first element is Function to be executed and rest are Function arguments 8 | * 9 | * @param {Function} Side Effect implementation 10 | * @param {...args} Side Effect arguments 11 | * 12 | * @return {Array} Side Effect Array 13 | */ 14 | export default (fn, ...args) => { 15 | invariant( 16 | fn && isFunction(fn), 17 | 'First Side Effect argument is always function' 18 | ); 19 | 20 | return [fn, ...args]; 21 | }; 22 | 23 | /** 24 | * Checks whether provided argument is Side Effect 25 | * 26 | * @param {Any} Anything 27 | * 28 | * @return {Bool} Check result 29 | */ 30 | export const isSideEffect = any => Array.isArray(any) && any.length > 0 && isFunction(any[0]); 31 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Simple invariant check. 3 | * 4 | * @param {Boolean} A condition to be met 5 | * 6 | * @param {String} An exception message to be thrown 7 | * @returns {void} 8 | */ 9 | export const invariant = (condition, message) => { 10 | if (!condition) { 11 | throw new Error(`Invariant violation: ${message}`); 12 | } 13 | }; 14 | 15 | /** 16 | * Checks whether provided argument is a function. 17 | * 18 | * @param {any} Anything 19 | * 20 | * @returns {Boolean} Result of function check 21 | */ 22 | export const isFunction = any => typeof any === 'function'; 23 | 24 | /** 25 | * Checks whether provided argument is undefined. 26 | * 27 | * @param {any} Anything 28 | * 29 | * @returns {Boolean} Result of undefined check 30 | */ 31 | export const isUndefined = any => typeof any === 'undefined'; 32 | 33 | /** 34 | * Checks whether provided argument is iterable. The value must be defined and 35 | * must contain function named next. 36 | * 37 | * @param {any} Anything 38 | * 39 | * @returns {Boolean} Result of iterable check 40 | */ 41 | export const isIterable = value => !isUndefined(value) && isFunction(value.next); 42 | 43 | /** 44 | * Checks whether provided argument is generator. 45 | * The implementation is quite fragile. The current state 46 | * however does not allow reliable implementation of `isGenerator` 47 | * function. 48 | * 49 | * @param {any} Anything 50 | * 51 | * @returns {Boolean} Result of generator check 52 | */ 53 | export const isGenerator = fn => { 54 | // Generator should never throw an exception because 55 | // it's not executed, only iterable is returned 56 | try { 57 | if (isFunction(fn)) { 58 | const result = fn(); 59 | return !!result && isFunction(result._invoke); 60 | } else { 61 | return false; 62 | } 63 | } catch (ex) { 64 | return false; 65 | } 66 | }; 67 | 68 | /** 69 | * Map implementation which takes iterable as an argument. 70 | * 71 | * @param {Iterable} 72 | * 73 | * @param {Function} 74 | * @returns {Array} Array mapped by mapper function 75 | */ 76 | export const mapIterable = (iterable, mapper) => { 77 | invariant(isIterable(iterable), 78 | 'First argument passed to mapIterable must be iterable'); 79 | 80 | invariant(isFunction(mapper), 81 | 'Second argument passed to mapIterable must be a function'); 82 | 83 | // Clojure like recur loop 84 | // It's not ideal to use for..of as it does not 85 | // return the last value in iteration loop 86 | const recur = acc => { 87 | const next = iterable.next(); 88 | acc.push(mapper(next)); 89 | 90 | // ES6 tail call 91 | return next.done ? acc : recur(acc); 92 | }; 93 | 94 | return recur([]); 95 | }; 96 | 97 | /** 98 | * Standard map implementation which works with type of Object 99 | * 100 | * @param {Object} Object to be mapped 101 | * 102 | * @param {Function} Mapper function 103 | * @returns {Object} Mapped object 104 | */ 105 | export const mapObject = (object, fn) => 106 | Object.keys(object).reduce((memo, key) => { 107 | memo[key] = fn(object[key], key); 108 | 109 | return memo; 110 | }, {}); 111 | 112 | /** 113 | * Standard map implementation which works with type of Object 114 | * and mapper in form of generator 115 | * 116 | * @param {Object} Object to be mapped 117 | * 118 | * @param {Function} Mapper function 119 | * @returns {Object} Mapped object 120 | */ 121 | export const generatorMapObject = function*(object, generatorFn) { 122 | invariant(isGenerator(generatorFn), 123 | 'First argument passed to filterGeneratorYieldedValues must be generator'); 124 | 125 | const keys = Object.keys(object); 126 | 127 | function* recur(memo, index) { 128 | memo[keys[index]] = yield* generatorFn(object[keys[index]], keys[index]); 129 | return keys[index + 1] ? yield* recur(memo, index + 1) : memo; 130 | } 131 | 132 | return yield* recur({}, 0); 133 | }; 134 | 135 | /** 136 | * Returns all the yield value within generator. 137 | * 138 | * @param {Object} Iterable created of generator 139 | * 140 | * @returns {Array} Array of yielded values 141 | */ 142 | export const filterGeneratorYieldedValues = iterable => { 143 | invariant(isIterable(iterable), 144 | 'First argument passed to getGeneratorReturnValue must be an iterable'); 145 | 146 | const recur = acc => { 147 | const next = iterable.next(); 148 | if (next.done) { 149 | return acc; 150 | } else { 151 | acc.push(next.value); 152 | 153 | return recur(acc); 154 | } 155 | }; 156 | 157 | return recur([]); 158 | }; 159 | 160 | /** 161 | * Returns the returned value withing generator 162 | * 163 | * @param {Object} Iterable created of generator 164 | * 165 | * @returns {any} Value returned in generator 166 | */ 167 | export const getGeneratorReturnValue = iterable => { 168 | invariant(isIterable(iterable), 169 | 'First argument passed to getGeneratorReturnValue must be generator'); 170 | 171 | const recur = () => { 172 | const next = iterable.next(); 173 | return next.done ? next.value : recur(); 174 | }; 175 | 176 | return recur(); 177 | }; 178 | -------------------------------------------------------------------------------- /test/combineReducers.test.js: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai'; 2 | 3 | import combineReducers from '../src/combineReducers'; 4 | import { getGeneratorReturnValue, filterGeneratorYieldedValues } from '../src/utils'; 5 | 6 | describe('Combine Reducers', () => { 7 | it('should return a composition reducer which returns reduction mapping all the reducers reduction by their keys', () => { 8 | const foo = function*(appState = 0) { 9 | return appState + 1; 10 | }; 11 | const bar = function*(appState = 0) { 12 | return appState + 2; 13 | }; 14 | 15 | const combinedReducer = combineReducers({foo, bar}); 16 | const reduction = getGeneratorReturnValue(combinedReducer(undefined, undefined)); 17 | 18 | assert.equal(reduction.foo, getGeneratorReturnValue(foo())); 19 | assert.equal(reduction.bar, getGeneratorReturnValue(bar())); 20 | assert.notEqual(reduction.foo, getGeneratorReturnValue(bar())); 21 | assert.notEqual(reduction.bar, getGeneratorReturnValue(foo())); 22 | }); 23 | 24 | it('should propagate all the yielded values when using combineReducers', () => { 25 | const foo = function*(appState = 0) { 26 | yield 1; 27 | yield 2; 28 | return appState + 1; 29 | }; 30 | const bar = function*(appState = 0) { 31 | yield 3; 32 | return appState + 1; 33 | }; 34 | 35 | const combinedReducer = combineReducers({foo, bar}); 36 | 37 | const yieldedValues = filterGeneratorYieldedValues(combinedReducer(undefined, undefined)); 38 | const reduction = getGeneratorReturnValue(combinedReducer(undefined, undefined)); 39 | 40 | assert.deepEqual(yieldedValues, [1, 2, 3]); 41 | assert.equal(reduction.foo, getGeneratorReturnValue(foo())); 42 | assert.equal(reduction.bar, getGeneratorReturnValue(bar())); 43 | }); 44 | 45 | it('should allow to pass either generators or standard functions as reducers', () => { 46 | const foo = function*(appState = 0) { 47 | return appState + 1; 48 | }; 49 | const bar = (appState = 0) => appState + 2; 50 | 51 | const combinedReducer = combineReducers({foo, bar}); 52 | const reduction = getGeneratorReturnValue(combinedReducer(undefined, undefined)); 53 | 54 | assert.equal(reduction.foo, getGeneratorReturnValue(foo())); 55 | assert.equal(reduction.bar, bar()); 56 | assert.notEqual(reduction.foo, bar()); 57 | assert.notEqual(reduction.bar, getGeneratorReturnValue(foo())); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /test/createEffectCapableStore.test.js: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai'; 2 | import { spy } from 'sinon'; 3 | import { createStore } from 'redux'; 4 | 5 | import sideEffect from '../src/sideEffect'; 6 | import createEffectCapableStore from '../src/createEffectCapableStore'; 7 | 8 | describe('Create effect capable store', () => { 9 | it('should execute all the effects in next call stack', done => { 10 | const effect = spy(); 11 | 12 | function* reducer() { 13 | yield sideEffect(effect, 42, 'foobar'); 14 | yield sideEffect(effect); 15 | return 1; 16 | } 17 | 18 | const storeFactory = createEffectCapableStore(createStore); 19 | const store = storeFactory(reducer); 20 | 21 | assert.isFalse(effect.called); 22 | 23 | setTimeout(() => { 24 | assert.equal(effect.callCount, 2); 25 | assert.deepEqual(effect.firstCall.args, [store.dispatch, 42, 'foobar']); 26 | assert.deepEqual(effect.secondCall.args, [store.dispatch]); 27 | done(); 28 | }); 29 | }); 30 | 31 | 32 | it('should allow to yield effect within action which has been dispatched through effect', done => { 33 | const effect = spy(); 34 | 35 | function* reducer(appState = 1, action) { 36 | if (action.type === 'first') { 37 | yield sideEffect(dispatch => dispatch({ type: 'second' })); 38 | } else if (action.type === 'second') { 39 | yield sideEffect(effect, 42); 40 | } 41 | 42 | return appState; 43 | } 44 | 45 | const storeFactory = createEffectCapableStore(createStore); 46 | const store = storeFactory(reducer); 47 | store.dispatch({ type: 'first' }); 48 | 49 | assert.isFalse(effect.called); 50 | 51 | setTimeout(() => { 52 | setTimeout(() => { 53 | assert.equal(effect.callCount, 1); 54 | assert.deepEqual(effect.firstCall.args, [store.dispatch, 42]); 55 | done(); 56 | }); 57 | }); 58 | }); 59 | 60 | it('should wrap the next reducer even when the replaceReducer is called', done => { 61 | const effect = spy(); 62 | function* testingReducer(appState, action) { 63 | if (action.type === 'foo') { 64 | yield sideEffect(effect, 42); 65 | } 66 | return 1; 67 | } 68 | 69 | const testingStore = createEffectCapableStore(createStore)(testingReducer); 70 | testingStore.replaceReducer(testingReducer); 71 | testingStore.dispatch({ type: 'foo' }); 72 | 73 | setTimeout(() => { 74 | assert.equal(effect.callCount, 1); 75 | assert.deepEqual(effect.firstCall.args, [testingStore.dispatch, 42]); 76 | done(); 77 | }); 78 | }); 79 | 80 | it('should ignore side effects when any action is dispatched while reducer is being replaced', done => { 81 | const effect = spy(); 82 | function* testingReducer(appState, action) { 83 | if (action.type === '@@redux/INIT') { 84 | yield sideEffect(effect, 42); 85 | } 86 | return 1; 87 | } 88 | 89 | const testingStore = createEffectCapableStore(createStore)(testingReducer); 90 | testingStore.replaceReducer(testingReducer); 91 | 92 | setTimeout(() => { 93 | assert.equal(effect.callCount, 1); 94 | assert.deepEqual(effect.firstCall.args, [testingStore.dispatch, 42]); 95 | done(); 96 | }); 97 | }); 98 | 99 | it('should return action result when dispatch is called so that middleware chain is not broken', () => { 100 | function* reducer() { 101 | return 1; 102 | } 103 | 104 | const action = { type: 'foo' }; 105 | const testingStore = createEffectCapableStore(createStore)(reducer); 106 | const result = testingStore.dispatch(action); 107 | assert.equal(result, action); 108 | }); 109 | 110 | it('should not execute effects when original dispatch function is called', done => { 111 | const effect = spy(); 112 | const returnValue = spy(() => 42); 113 | 114 | function* testingReducer() { 115 | yield sideEffect(effect); 116 | return returnValue(); 117 | } 118 | 119 | const storeEnhancer = reducer => { 120 | const store = createStore(reducer, undefined); 121 | 122 | return { 123 | ...store, 124 | liftedDispatch: store.dispatch 125 | }; 126 | }; 127 | 128 | const store = createEffectCapableStore(storeEnhancer)(testingReducer); 129 | store.liftedDispatch({ 130 | type: 'FOO' 131 | }); 132 | 133 | assert.equal(returnValue.callCount, 2); 134 | setTimeout(() => { 135 | assert.equal(effect.callCount, 1); 136 | done(); 137 | }); 138 | }); 139 | }); 140 | -------------------------------------------------------------------------------- /test/enhanceReducer.test.js: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai'; 2 | import { spy } from 'sinon'; 3 | 4 | import enhanceReducer from '../src/enhanceReducer'; 5 | import sideEffect from '../src/sideEffect'; 6 | 7 | describe('Enhance Reducer', () => { 8 | it('should throw an exception when root reducer is not a function', () => { 9 | const enhanced = enhanceReducer(false); 10 | 11 | try { 12 | enhanced({}); 13 | } catch (ex) { 14 | assert.equal(ex.message, 'Invariant violation: Provided root reducer is not a function.'); 15 | } 16 | }); 17 | 18 | it('should return app state and execute side effect with provided arguments', () => { 19 | const effect = () => {}; 20 | const executor = spy(); 21 | 22 | function* rootReducer() { 23 | yield sideEffect(effect, 42, 'foobar'); 24 | yield sideEffect(effect, 43); 25 | return 3; 26 | } 27 | 28 | const enchanced = enhanceReducer(rootReducer, executor); 29 | const reduction = enchanced({}, {}); 30 | 31 | assert.equal(reduction, 3); 32 | assert.deepEqual(executor.firstCall.args, [ 33 | [ 34 | sideEffect(effect, 42, 'foobar'), 35 | sideEffect(effect, 43) 36 | ] 37 | ]); 38 | }); 39 | 40 | it('should be allowed to yield only sideEffects', () => { 41 | function* invalidReducer() { 42 | yield 1; 43 | yield sideEffect(() => {}); 44 | return 4; 45 | } 46 | 47 | function* validReducer() { 48 | yield sideEffect(() => {}); 49 | yield sideEffect(() => {}, 'foo', 'bar'); 50 | return 42; 51 | } 52 | 53 | try { 54 | enhanceReducer(invalidReducer, () => {})({}, {}); 55 | assert.isTrue(false); 56 | } catch (ex) { 57 | assert.equal(ex.message, 'Invariant violation: Yielded side effects must always be created using sideEffect function'); 58 | } 59 | 60 | assert.equal(enhanceReducer(validReducer, () => {})({}, {}), 42); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /test/sideEffect.test.js: -------------------------------------------------------------------------------- 1 | import { assert } from 'chai'; 2 | 3 | import sideEffect, { isSideEffect } from '../src/sideEffect'; 4 | 5 | describe('Side Effect descriptor', () => { 6 | it('should throw an exception when no arguments are provided', () => { 7 | try { 8 | sideEffect(); 9 | assert.isTrue(false); 10 | } catch (ex) { 11 | assert.equal(ex.message, 'Invariant violation: First Side Effect argument is always function'); 12 | } 13 | }); 14 | 15 | it('should return an array where first element is function to be executed', () => { 16 | const impl = () => {}; 17 | assert.deepEqual(sideEffect(impl), [ impl ]); 18 | }); 19 | 20 | it('should return an array where first element is function to be executed and rest are arguments', () => { 21 | const impl = () => {}; 22 | const arg1 = 'foobar'; 23 | const arg2 = 42; 24 | const arg3 = false; 25 | 26 | assert.deepEqual(sideEffect(impl, arg1, arg2, arg3), [impl, arg1, arg2, arg3]); 27 | }); 28 | }); 29 | 30 | describe('is effect checker', () => { 31 | it('should return false if anything except array is provided', () => { 32 | assert.isFalse(isSideEffect(false)); 33 | assert.isFalse(isSideEffect()); 34 | assert.isFalse(isSideEffect(() => {})); 35 | assert.isFalse(isSideEffect({})); 36 | assert.isFalse(isSideEffect(new Date())); 37 | assert.isFalse(isSideEffect(42)); 38 | assert.isFalse(isSideEffect('foobar')); 39 | }); 40 | 41 | it('should return true when array is provided and first argument is function', () => { 42 | assert.isTrue(isSideEffect(sideEffect(() => {}, 'foo', 'bar'))); 43 | }); 44 | 45 | it('should return false when array is provided and first agument is not function', () => { 46 | assert.isFalse(isSideEffect(['foo', 'bar'])); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /test/utils.test.js: -------------------------------------------------------------------------------- 1 | import { spy } from 'sinon'; 2 | import { assert } from 'chai'; 3 | 4 | import * as Utils from '../src/utils'; 5 | 6 | describe('Utils', () => { 7 | const exceptionMessage = 'mock'; 8 | function* generator() { 9 | yield 1; 10 | yield 2; 11 | return 3; 12 | } 13 | 14 | it('should throw an exception when invariant condition is not met', () => { 15 | try { 16 | spy(Utils, 'invariant'); 17 | 18 | Utils.invariant(false, exceptionMessage); 19 | } catch (ex) { 20 | assert.match(Utils.invariant.exceptions[0].message, new RegExp(exceptionMessage)); 21 | } 22 | }); 23 | 24 | it('should not throw an exception when invariant condition is met', () => { 25 | Utils.invariant(true, exceptionMessage); 26 | assert.isTrue(true); 27 | }); 28 | 29 | it('should return true when isFunction takes function as an argument', () => { 30 | assert.isTrue(Utils.isFunction(() => {})); 31 | }); 32 | 33 | it('should return false when isFunction does not take function as an argument', () => { 34 | assert.isFalse(Utils.isFunction(false)); 35 | }); 36 | 37 | it('should return true when isUndefined takes undefined as an argument', () => { 38 | assert.isTrue(Utils.isUndefined(undefined)); 39 | }); 40 | 41 | it('should return false when isUndefined does not take undefined as an argument', () => { 42 | assert.isFalse(Utils.isUndefined(false)); 43 | }); 44 | 45 | it('should return true when isIterable takes generator function result as an argument', () => { 46 | const generatorFunction = function* () {}; 47 | 48 | assert.isTrue(Utils.isIterable(generatorFunction())); 49 | }); 50 | 51 | it('should return false when isIterable does not take generator function result as an argument', () => { 52 | assert.isFalse(Utils.isIterable(false)); 53 | }); 54 | 55 | 56 | it('should call the mapper function as many times as there are elements in the iterable', () => { 57 | const mapper = spy(() => {}); 58 | 59 | Utils.mapIterable(generator(), mapper); 60 | 61 | assert.equal(mapper.callCount, 3); 62 | }); 63 | 64 | it('should return the mapped array', () => { 65 | const mapper = val => val.value + 1; 66 | 67 | const mapped = Utils.mapIterable(generator(), mapper); 68 | 69 | assert.deepEqual(mapped, [2, 3, 4]); 70 | }); 71 | 72 | it('should return true if generator is provided', () => { 73 | assert.isTrue(Utils.isGenerator(function*() {})); 74 | }); 75 | 76 | it('should return false if generator is not provided', () => { 77 | assert.isFalse(Utils.isGenerator(() => {})); 78 | }); 79 | 80 | it('should use the mapper to map over the object', () => { 81 | const map = { 82 | 'foo': 0, 83 | 'bar': 1 84 | }; 85 | 86 | const mapped = Utils.mapObject(map, (value, key) => `${key}${value + 1}`); 87 | assert.deepEqual(mapped, { 88 | 'foo': 'foo1', 89 | 'bar': 'bar2' 90 | }); 91 | }); 92 | 93 | it('should use the generator mapper to map over object and yield values', () => { 94 | const map = { 95 | 'foo': 0, 96 | 'bar': 1 97 | }; 98 | 99 | function* generatorMapper(value, key) { 100 | yield key + value; 101 | return value + 1; 102 | } 103 | 104 | assert.deepEqual({'foo': 1, 'bar': 2}, Utils.getGeneratorReturnValue(Utils.generatorMapObject(map, generatorMapper))); 105 | assert.deepEqual(['foo0', 'bar1'], Utils.filterGeneratorYieldedValues(Utils.generatorMapObject(map, generatorMapper))); 106 | }); 107 | 108 | it('should filter in yielded values', () => { 109 | assert.deepEqual(Utils.filterGeneratorYieldedValues(generator()), [1, 2]); 110 | }); 111 | 112 | it('should return returned value withing generator', () => { 113 | assert.equal(Utils.getGeneratorReturnValue(generator()), 3); 114 | }); 115 | 116 | it('should caught any exception thrown by function provided to isGenerator', () => { 117 | function nonGeneratorWithException() { 118 | throw new Error('This should be caught'); 119 | } 120 | 121 | assert.isFalse(Utils.isGenerator(nonGeneratorWithException)); 122 | }); 123 | }); 124 | --------------------------------------------------------------------------------