├── .babelrc ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE.md ├── Makefile ├── README.md ├── lib └── index.js ├── package.json └── src ├── __tests__ └── .gitkeep └── index.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-0"], 3 | "plugins": [] 4 | } 5 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .gitignore -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "commonjs": true, 5 | "es6": true 6 | }, 7 | "extends": ["eslint:recommended"], 8 | "parserOptions": { 9 | "ecmaFeatures": { 10 | "experimentalObjectRestSpread": true, 11 | "jsx": true, 12 | "classes": true 13 | }, 14 | "sourceType": "module" 15 | }, 16 | "plugins": [], 17 | "parser": "babel-eslint", 18 | "rules": { 19 | "indent": [ 20 | "error", 21 | 2 22 | ], 23 | "linebreak-style": [ 24 | "error", 25 | "unix" 26 | ], 27 | "quotes": [ 28 | "error", 29 | "single" 30 | ], 31 | "semi": [ 32 | "error", 33 | "always" 34 | ], 35 | "quotes": "off" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | dist 3 | node_modules 4 | .DS_Store 5 | .idea 6 | npm-debug.log 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .babelrc 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - "5" 5 | 6 | cache: 7 | directories: 8 | - node_modules 9 | 10 | script: 11 | - npm run test 12 | - npm run build 13 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Chion Tang 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BIN=node_modules/.bin 2 | 3 | build: 4 | $(BIN)/babel src --out-dir lib 5 | 6 | clean: 7 | rm -rf lib 8 | 9 | test: lint 10 | NODE_ENV=test echo 'No test scripts specified.' 11 | 12 | lint: 13 | $(BIN)/eslint src 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Redux Wait for Action 2 | -------------------- 3 | [![Build Status](https://travis-ci.org/Chion82/redux-wait-for-action.svg?branch=master)](https://travis-ci.org/Chion82/redux-wait-for-action) 4 | [![npm version](https://badge.fury.io/js/redux-wait-for-action.svg)](https://badge.fury.io/js/redux-wait-for-action) 5 | 6 | Redux middleware to make `store.dispatch()` return a promise which will be fulfilled when another specified action is dispatched, which is useful for universal(isomorphic) React Web Apps with redux and server-side rendering. 7 | 8 | ``` 9 | npm install --save redux-wait-for-action 10 | ``` 11 | 12 | Quick Start 13 | ----------- 14 | [Minimal starter kit for universal apps with redux and redux-saga](https://github.com/Chion82/react-redux-universal-minimal) 15 | 16 | Basic Usage 17 | ----------- 18 | To fire `todos/get` action and subscribe for `todos/get/success` action: 19 | ```javascript 20 | import { WAIT_FOR_ACTION, ERROR_ACTION } from 'redux-wait-for-action'; 21 | store.dispatch({ 22 | type: 'todos/get', 23 | [ WAIT_FOR_ACTION ]: 'todos/get/success', // Specify which action we are waiting for 24 | [ ERROR_ACTION ]: 'todos/get/failed', // Optional 25 | }).then( payload => console.log('Todos got!') ) 26 | .catch( error => console.error('Failed!' + error.message) ); 27 | ``` 28 | Alternatively, use conditional functions as `WAIT_FOR_ACTION`, which is useful when firing multiple actions with same `action.type` in parallel: 29 | ```javascript 30 | store.dispatch({ 31 | type: 'profile/get', 32 | [ WAIT_FOR_ACTION ]: action => action.type === 'profile/get/success' && action.id === 1, 33 | // Only subscribe for profile/get/success action whose profile id equals 1 34 | [ ERROR_ACTION ]: action => action.type === 'profile/get/failed' && action.id === 1, 35 | }).then( payload => console.log('ID #1 Profile got!') ) 36 | .catch( error => console.error('Failed!' + error.message) ); 37 | ``` 38 | 39 | For Isomorphic Apps 40 | ------------------- 41 | * For each React container, we define a static function `fetchData()` where we return a `store.dispatch()` call followed by automatic execution of side effects. We should call this `store.dispatch()` with an action that also contains information about which action we are waiting for. 42 | * Use those `fetchData()`s to populate page data on **both client and server side**. 43 | * On server side, we put the rendering logic in `fetchData().then(() => { /* rendering logic here! */ })`, where side effects are completed and an action with finishing flag is dispatched. 44 | * If you are using [redux-thunk](https://github.com/gaearon/redux-thunk), `store.dispatch()` already returns a promise and you probably don't need this middleware. However, side effects like [redux-saga](https://github.com/yelouafi/redux-saga) running separately from primitive Redux flow don't explicitly notify us when a specific async fetch is finished, in which case redux-wait-for-action does the trick and makes those async tasks subscribable. 45 | * Although redux-saga added `runSaga().done` support which returns a promise to tell when a specific saga task is completed, it's quite tricky where saga tasks aren't started by a `dispatch()` call and it does't work when using sagas containing infinite loops. 46 | 47 | Usage with react-router and redux-saga 48 | -------------------------------------- 49 | `configureStore()` function where a Redux store is created on **both client and server side**: 50 | ```javascript 51 | import createReduxWaitForMiddleware from 'redux-wait-for-action'; 52 | 53 | function configureStore(initialState) { 54 | const sagaMiddleware = createSagaMiddleware(); 55 | let enhancer = compose( 56 | applyMiddleware(sagaMiddleware), 57 | applyMiddleware(createReduxWaitForMiddleware()), 58 | ); 59 | const store = createStore(rootReducer, initialState, enhancer); 60 | 61 | // ... 62 | } 63 | ``` 64 | Assume we have saga effects like this: 65 | ```javascript 66 | function* getTodosSaga() { 67 | const payload = yield call(APIService.getTodos); 68 | yield put({ 69 | type: 'todos/get/success', 70 | payload 71 | }); 72 | } 73 | function* rootSaga() { 74 | yield takeLatest('todos/get', getTodosSaga); 75 | } 76 | ``` 77 | Define a `fetchData()` for each of our containers: 78 | ```javascript 79 | import { WAIT_FOR_ACTION } from 'redux-wait-for-action'; 80 | 81 | class TodosContainer extends Component { 82 | static fetchData(dispatch) { 83 | return dispatch({ 84 | type: 'todos/get', 85 | [ WAIT_FOR_ACTION ]: 'todos/get/success', 86 | }); 87 | } 88 | componentDidMount() { 89 | // Populate page data on client side 90 | TodosContainer.fetchData(this.props.dispatch); 91 | } 92 | // ... 93 | } 94 | ``` 95 | Here in our action we specify `WAIT_FOR_ACTION` as `'profile/get/success'`, which tells our promise to wait for another action `'profile/get/success'`. `WAIT_FOR_ACTION` is a ES6 `Symbol` instance rather than a string, so feel free using it and it won't contaminate your action. 96 | 97 | Next for server side rendering, we reuse those `fetchData()`s to get the data we need: 98 | ```javascript 99 | //handler for Express.js 100 | app.use('*', handleRequest); 101 | function handleRequest(req, res, next) { 102 | //... 103 | match({history, routes, location: req.url}, (error, redirectLocation, renderProps) => { 104 | //...handlers for redirection, error and null renderProps... 105 | 106 | const getReduxPromise = () => { 107 | const component = renderProps.components[renderProps.components.length - 1].WrappedComponent; 108 | const promise = component.fetchData ? 109 | component.fetchData(store.dispatch) : 110 | Promise.resolve(); 111 | return promise; 112 | }; 113 | 114 | getReduxPromise().then(() => { 115 | const initStateString = JSON.stringify(store.getState()); 116 | const html = ReactDOMServer.renderToString( 117 | 118 | { } 119 | 120 | ); 121 | res.status(200).send(renderFullPage(html, initStateString)); 122 | }); 123 | }); 124 | } 125 | ``` 126 | 127 | 128 | Advanced Usage 129 | -------------- 130 | ### Error Handling 131 | 132 | Use `try-catch` clause in saga effects. The `todos/get/failed` action object should contain a top-level key `error` or `err` whose value is an error descriptor(An `Error()` instance or a string). 133 | ```javascript 134 | function* getTodosSaga() { 135 | yield take('todos/get'); 136 | try { 137 | const payload = yield call(APIService.getTodos); 138 | yield put({ 139 | type: 'todos/get/success', 140 | payload 141 | }); 142 | } catch (error) { 143 | yield put({ 144 | type: 'todos/get/failed', 145 | error 146 | }); 147 | } 148 | } 149 | ``` 150 | Make sure both `WAIT_FOR_ACTION` and `ERROR_ACTION` symbols are specified in your `todos/get` action: 151 | ```javascript 152 | import { WAIT_FOR_ACTION, ERROR_ACTION } from 'redux-wait-for-action'; 153 | 154 | class TodosContainer extends Component { 155 | static fetchData(dispatch) { 156 | return dispatch({ 157 | type: 'todos/get', 158 | [ WAIT_FOR_ACTION ]: 'todos/get/success', 159 | [ ERROR_ACTION ]: 'todos/get/failed', 160 | }); 161 | } 162 | // ... 163 | } 164 | ``` 165 | Server side rendering logic: 166 | ```javascript 167 | getReduxPromise().then(() => { 168 | // ... 169 | res.status(200).send(renderFullPage(html, initStateString)); 170 | }).catch((error) => { //action.error is passed to here 171 | res.status(500).send(error.message); 172 | }); 173 | ``` 174 | 175 | ### Overriding the Default Promise Arguments 176 | 177 | By default the `payload` or `data` field on the `WAIT_FOR_ACTION` action is provided to the promise when it is resolved, or rejected with the `error` or `err` field. 178 | 179 | There are two additional symbols, `CALLBACK_ARGUMENT` and `CALLBACK_ERROR_ARGUMENT`, which can be used to override this behavior. If functions are stored on the action using these symbols, they will be invoked and passed the entire action. The result returned from either function is used to resolve or reject the promise based on which symbol was used. 180 | 181 | ```javascript 182 | import { WAIT_FOR_ACTION, ERROR_ACTION, CALLBACK_ARGUMENT, CALLBACK_ERROR_ARGUMENT} from 'redux-wait-for-action'; 183 | store.dispatch({ 184 | type: 'todos/get', 185 | [ WAIT_FOR_ACTION ]: 'todos/get/success', 186 | [ ERROR_ACTION ]: 'todos/get/failed', 187 | [ CALLBACK_ARGUMENT ]: action => action.customData, 188 | [ CALLBACK_ERROR_ARGUMENT ]: action => action.customError, 189 | }).then( customData => console.log('Custom Data: ', customData) ) 190 | .catch( customError => console.error('Custom Error: ', customError) ); 191 | ``` -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | 7 | exports.default = function () { 8 | var pendingActionList = []; 9 | var promisesList = []; 10 | var getPromisesList = function getPromisesList() { 11 | return promisesList; 12 | }; 13 | 14 | //eslint-disable-next-line 15 | var middleware = function middleware(store) { 16 | return function (next) { 17 | return function (action) { 18 | 19 | for (var i = pendingActionList.length - 1; i >= 0; i--) { 20 | var pendingActionInfo = pendingActionList[i]; 21 | if (pendingActionInfo.isSuccessAction(action)) { 22 | pendingActionInfo.resolveCallback(pendingActionInfo.successArgumentCb(action)); 23 | } else if (pendingActionInfo.isErrorAction(action)) { 24 | pendingActionInfo.rejectCallback(pendingActionInfo.errorArgumentCb(action)); 25 | } else { 26 | continue; 27 | } 28 | pendingActionList.splice(pendingActionList.indexOf(pendingActionInfo), 1); 29 | } 30 | 31 | if (!action[WAIT_FOR_ACTION]) { 32 | return next(action); 33 | } 34 | 35 | var successAction = action[WAIT_FOR_ACTION]; 36 | var errorAction = action[ERROR_ACTION]; 37 | 38 | var newPendingActionInfo = {}; 39 | 40 | if (typeof successAction === 'function') { 41 | newPendingActionInfo.isSuccessAction = successAction; 42 | } else { 43 | newPendingActionInfo.isSuccessAction = function (action) { 44 | return action.type === successAction; 45 | }; 46 | } 47 | 48 | if (errorAction) { 49 | if (typeof errorAction === 'function') { 50 | newPendingActionInfo.isErrorAction = errorAction; 51 | } else { 52 | newPendingActionInfo.isErrorAction = function (action) { 53 | return action.type === errorAction; 54 | }; 55 | } 56 | } else { 57 | newPendingActionInfo.isErrorAction = function () { 58 | return false; 59 | }; 60 | } 61 | 62 | newPendingActionInfo.successArgumentCb = action[CALLBACK_ARGUMENT] || fsaCompliantArgumentCb; 63 | newPendingActionInfo.errorArgumentCb = action[CALLBACK_ERROR_ARGUMENT] || fsaCompliantErrorArgumentCb; 64 | 65 | var promise = new Promise(function (resolve, reject) { 66 | newPendingActionInfo.resolveCallback = resolve; 67 | newPendingActionInfo.rejectCallback = reject; 68 | }); 69 | 70 | pendingActionList.push(newPendingActionInfo); 71 | promisesList.push(promise); 72 | 73 | next(action); 74 | 75 | return promise; 76 | }; 77 | }; 78 | }; 79 | 80 | return Object.assign(middleware, { getPromisesList: getPromisesList }); 81 | }; 82 | 83 | var WAIT_FOR_ACTION = Symbol('WAIT_FOR_ACTION'); 84 | var ERROR_ACTION = Symbol('ERROR_ACTION'); 85 | var CALLBACK_ARGUMENT = Symbol('CALLBACK_ARGUMENT'); 86 | var CALLBACK_ERROR_ARGUMENT = Symbol('ERROR_CALLBACK_ARGUMENT'); 87 | 88 | exports.WAIT_FOR_ACTION = WAIT_FOR_ACTION; 89 | exports.ERROR_ACTION = ERROR_ACTION; 90 | exports.CALLBACK_ARGUMENT = CALLBACK_ARGUMENT; 91 | exports.CALLBACK_ERROR_ARGUMENT = CALLBACK_ERROR_ARGUMENT; 92 | 93 | 94 | var fsaCompliantArgumentCb = function fsaCompliantArgumentCb(action) { 95 | return action.payload || action.data || {}; 96 | }; 97 | var fsaCompliantErrorArgumentCb = function fsaCompliantErrorArgumentCb(action) { 98 | return action.error || action.err || new Error('action.error not specified.'); 99 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-wait-for-action", 3 | "version": "0.0.7", 4 | "description": "Redux middleware to make store.dispatch() return a promise and wait for another action", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "build": "make build", 8 | "test": "make test", 9 | "lint": "make lint" 10 | }, 11 | "author": "Chion Tang ", 12 | "license": "MIT", 13 | "devDependencies": { 14 | "babel-cli": "^6.18.0", 15 | "babel-eslint": "^7.1.1", 16 | "babel-preset-es2015": "^6.18.0", 17 | "babel-preset-stage-0": "^6.16.0", 18 | "eslint": "^3.12.2", 19 | "mocha": "^3.2.0" 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "https://github.com/Chion82/redux-wait-for-action" 24 | }, 25 | "keywords": [ 26 | "redux", 27 | "redux-saga", 28 | "react" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /src/__tests__/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Chion82/redux-wait-for-action/c617bafc55308e57634e32336a856408184ddde4/src/__tests__/.gitkeep -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const WAIT_FOR_ACTION = Symbol('WAIT_FOR_ACTION'); 2 | const ERROR_ACTION = Symbol('ERROR_ACTION'); 3 | const CALLBACK_ARGUMENT = Symbol('CALLBACK_ARGUMENT'); 4 | const CALLBACK_ERROR_ARGUMENT = Symbol('ERROR_CALLBACK_ARGUMENT'); 5 | 6 | export { WAIT_FOR_ACTION, ERROR_ACTION, CALLBACK_ARGUMENT, CALLBACK_ERROR_ARGUMENT }; 7 | 8 | const fsaCompliantArgumentCb = action => action.payload || action.data || {}; 9 | const fsaCompliantErrorArgumentCb = action => action.error || action.err || new Error('action.error not specified.'); 10 | 11 | export default function() { 12 | const pendingActionList = []; 13 | const promisesList = []; 14 | const getPromisesList = () => promisesList; 15 | 16 | //eslint-disable-next-line 17 | const middleware = store => next => action => { 18 | 19 | for (let i = pendingActionList.length - 1; i >= 0; i--) { 20 | const pendingActionInfo = pendingActionList[i]; 21 | if (pendingActionInfo.isSuccessAction(action)) { 22 | pendingActionInfo.resolveCallback(pendingActionInfo.successArgumentCb(action)); 23 | } else if (pendingActionInfo.isErrorAction(action)) { 24 | pendingActionInfo.rejectCallback(pendingActionInfo.errorArgumentCb(action)); 25 | } else { 26 | continue; 27 | } 28 | pendingActionList.splice(pendingActionList.indexOf(pendingActionInfo), 1); 29 | } 30 | 31 | if (!action[WAIT_FOR_ACTION]) { 32 | return next(action); 33 | } 34 | 35 | const successAction = action[WAIT_FOR_ACTION]; 36 | const errorAction = action[ERROR_ACTION]; 37 | 38 | const newPendingActionInfo = {}; 39 | 40 | if (typeof successAction === 'function') { 41 | newPendingActionInfo.isSuccessAction = successAction; 42 | } else { 43 | newPendingActionInfo.isSuccessAction = action => action.type === successAction; 44 | } 45 | 46 | if (errorAction) { 47 | if (typeof errorAction === 'function') { 48 | newPendingActionInfo.isErrorAction = errorAction; 49 | } else { 50 | newPendingActionInfo.isErrorAction = action => action.type === errorAction; 51 | } 52 | } else { 53 | newPendingActionInfo.isErrorAction = () => false; 54 | } 55 | 56 | newPendingActionInfo.successArgumentCb = action[CALLBACK_ARGUMENT] || fsaCompliantArgumentCb; 57 | newPendingActionInfo.errorArgumentCb = action[CALLBACK_ERROR_ARGUMENT] || fsaCompliantErrorArgumentCb; 58 | 59 | const promise = new Promise((resolve, reject) => { 60 | newPendingActionInfo.resolveCallback = resolve; 61 | newPendingActionInfo.rejectCallback = reject; 62 | }); 63 | 64 | pendingActionList.push(newPendingActionInfo); 65 | promisesList.push(promise); 66 | 67 | next(action); 68 | 69 | return promise; 70 | 71 | }; 72 | 73 | return Object.assign(middleware, { getPromisesList }); 74 | } 75 | --------------------------------------------------------------------------------