├── .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 | [](https://travis-ci.org/Chion82/redux-wait-for-action)
4 | [](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 |
--------------------------------------------------------------------------------