├── .babelrc ├── .editorconfig ├── .gitignore ├── .npmignore ├── .travis.yml ├── README.md ├── package.json ├── src ├── connect.js ├── constants.js ├── index.js ├── middleware.js ├── reducer.js └── utils.js └── test ├── redux-e2e.js ├── test-redux ├── code-to-run.js ├── project │ └── index.js └── run-e2e-test.js └── units.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-0"] 3 | } 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib/ 2 | node_modules/ 3 | coverage/ 4 | .nyc_output/ 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | examples 3 | test.js 4 | .babelrc 5 | .npmignore 6 | .travis.yml 7 | oldtest.js 8 | loader.js 9 | .nyc_output/ 10 | test/ 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "iojs" 4 | - "4" 5 | - "5" 6 | script: "npm run-script test-travis" 7 | # Send coverage data to Coveralls 8 | after_script: "cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js" 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | redux-await 2 | ============= 3 | 4 | [![NPM version][npm-image]][npm-url] 5 | [![Build status][travis-image]][travis-url] 6 | [![Test coverage][coveralls-image]][coveralls-url] 7 | [![Downloads][downloads-image]][downloads-url] 8 | 9 | Manage async redux actions sanely 10 | 11 | # Breaking Changes!! 12 | `redux-await` now takes control of a branch of your state/reducer tree similar to `redux-form`, and also like `redux-form` you need to use this module's version of `connect` and not `react-redux`'s 13 | 14 | 15 | ## Install 16 | 17 | ```js 18 | npm install --save redux-await 19 | ``` 20 | 21 | ## Usage 22 | 23 | This module exposes a middleware, reducer, and connector to take care of async state in a redux 24 | app. You'll need to: 25 | 26 | 1. Apply the middleware: 27 | 28 | ```js 29 | import { middleware as awaitMiddleware } from 'redux-await'; 30 | let createStoreWithMiddleware = applyMiddleware( 31 | awaitMiddleware 32 | )(createStore); 33 | ``` 34 | 35 | 2. Install the reducer into the `await` path of your `combineReducers` 36 | 37 | ```js 38 | import reducers from './reducers'; 39 | 40 | // old code 41 | // const store = applyMiddleware(thunk)(createStore)(reducers); 42 | 43 | // new code 44 | import { reducer as awaitReducer } from 'redux-await'; 45 | const store = applyMiddleware(thunk, awaitMiddleware)(createStore)({ 46 | ...reducers, 47 | await: awaitReducer, 48 | }); 49 | ``` 50 | 51 | 3. Use the `connect` function from this module and not `react-redux`'s 52 | 53 | ```js 54 | // old code 55 | // import { connect } from 'react-redux'; 56 | 57 | // new code 58 | import { connect } from 'redux-await'; 59 | 60 | class FooPage extends Component { 61 | render() { /* ... */ } 62 | } 63 | 64 | export default connect(state => state.foo)(FooPage) 65 | 66 | ``` 67 | 68 | 69 | Now your action payloads can contain promises, you just need to add `AWAIT_MARKER` to the 70 | action like this: 71 | 72 | ```js 73 | // old code 74 | //export const getTodos = () => ({ 75 | // type: GET_TODOS, 76 | // payload: { 77 | // loadedTodos: localStorage.todos, 78 | // }, 79 | //}); 80 | //export const addTodo = todo => ({ 81 | // type: ADD_TODO, 82 | // payload: { 83 | // savedTodo: todo, 84 | // }, 85 | //}); 86 | 87 | // new code 88 | import { AWAIT_MARKER } from 'redux-await'; 89 | export const getTodos = () => ({ 90 | type: GET_TODOS, 91 | AWAIT_MARKER, 92 | payload: { 93 | loadedTodos: api.getTodos(), // returns promise 94 | }, 95 | }); 96 | export const addTodo = todo => ({ 97 | type: ADD_TODO, 98 | AWAIT_MARKER, 99 | payload: { 100 | savedTodo: api.saveTodo(todo), // returns promise 101 | }, 102 | }); 103 | ``` 104 | 105 | Now your containers barely need to change: 106 | 107 | ```js 108 | class Container extends Component { 109 | render() { 110 | const { todos, statuses, errors } = this.props; 111 | 112 | // old code 113 | //return
114 | // 115 | //
; 116 | 117 | // new code 118 | return
119 | { statuses.loadedTodos === 'pending' &&
Loading...
} 120 | { statuses.loadedTodos === 'success' && } 121 | { statuses.loadedTodos.status === 'failure' &&
Oops: {errors.loadedTodos.message}
} 122 | { statuses.savedTodo === 'pending' &&
Saving new savedTodo
} 123 | { statuses.savedTodo === 'failure' &&
There was an error saving
} 124 |
; 125 | } 126 | } 127 | 128 | //old code 129 | // import { connect } from 'react-redux'; 130 | 131 | // new code 132 | import { connect } from 'redux-await'; // it just spreads state.await on props 133 | 134 | export default connect(state => state.todos)(Container) 135 | ``` 136 | 137 | # Why 138 | 139 | Redux is mostly concerned about how to manage state in a synchronous setting. Async apps create 140 | challenges like keeping track of the async status and dealing with async errors. 141 | While it is possible to build an app this way using 142 | [redux-thunk](https://github.com/gaearon/redux-thunk) 143 | and/or 144 | [redux-promise](https://github.com/acdlite/redux-promise) 145 | it tends to bloat the app and it makes unit testing needlessly verbose 146 | 147 | `redux-await` tries to solve all of these problems by keeping track of async payloads by means 148 | of a middleware and a reducer keeping track of payload properties statuses. Let's walk 149 | through the development of a TODO app (App 1) that starts without any async and then needs to 150 | start converting action from sync to async. We'll first try only using `redux-thunk` to solve 151 | this (App 2), and then see how to solve this with `redux-await` (App 3) 152 | 153 | For the first version of the app we're going to store the todos in localStorage. Here's a simple way we would do it: 154 | 155 | ## [App1 demo](http://kolodny.github.io/redux-await/app1/) 156 | ### App 1 157 | ```js 158 | import React, { Component } from 'react'; 159 | import ReactDOM from 'react-dom'; 160 | import { Provider, connect } from 'react-redux'; 161 | import { applyMiddleware, createStore, combineReducers } from 'redux'; 162 | import thunk from 'redux-thunk'; 163 | import createLogger from 'redux-logger'; 164 | 165 | const GET_TODOS = 'GET_TODOS'; 166 | const ADD_TODO = 'ADD_TODO'; 167 | const SAVE_APP = 'SAVE_APP'; 168 | const actions = { 169 | getTodos() { 170 | const todos = JSON.parse(localStorage.todos || '[]'); 171 | return { type: GET_TODOS, payload: { todos } }; 172 | }, 173 | addTodo(todo) { 174 | return { type: ADD_TODO, payload: { todo } }; 175 | }, 176 | saveApp() { 177 | return (dispatch, getState) => { 178 | localStorage.todos = JSON.stringify(getState().todos.todos); 179 | dispatch({ type: SAVE_APP }); 180 | } 181 | }, 182 | }; 183 | const initialState = { isAppSynced: false, todos: [] }; 184 | const todosReducer = (state = initialState, action = {}) => { 185 | if (action.type === GET_TODOS) { 186 | return { ...state, isAppSynced: true, todos: action.payload.todos }; 187 | } 188 | if (action.type === ADD_TODO) { 189 | return { ...state, isAppSynced: false, todos: state.todos.concat(action.payload.todo) }; 190 | } 191 | if (action.type === SAVE_APP) { 192 | return { ...state, isAppSynced: true }; 193 | } 194 | return state; 195 | }; 196 | const reducer = combineReducers({ 197 | todos: todosReducer, 198 | }) 199 | const store = applyMiddleware(thunk, createLogger())(createStore)(reducer); 200 | 201 | class App extends Component { 202 | componentDidMount() { 203 | this.props.dispatch(actions.getTodos()); 204 | } 205 | render() { 206 | const { dispatch, todos, isAppSynced } = this.props; 207 | const { input } = this.refs; 208 | return
209 | {isAppSynced && 'app is synced up'} 210 | 211 | dispatch(actions.addTodo(input.value))} /> 212 | 213 |
214 |
{JSON.stringify(store.getState(), null, 2)}
215 |
; 216 | } 217 | } 218 | const ConnectedApp = connect(state => state.todos)(App); 219 | 220 | ReactDOM.render(, document.getElementById('root')); 221 | ``` 222 | 223 | Looks cool (it's a POC so it's purposely minimal), but let's say you want to start using an API 224 | which is async to store the state, now your app will look something like App 2: 225 | 226 | ## [App2 demo](http://kolodny.github.io/redux-await/app2/) 227 | ### App 2 228 | ```js 229 | import React, { Component } from 'react'; 230 | import ReactDOM from 'react-dom'; 231 | import { Provider, connect } from 'react-redux'; 232 | import { applyMiddleware, createStore, combineReducers } from 'redux'; 233 | import thunk from 'redux-thunk'; 234 | import createLogger from 'redux-logger'; 235 | 236 | // this not an API, this is a tribute 237 | const api = { 238 | save(data) { 239 | return new Promise(resolve => { 240 | setTimeout(() => { 241 | localStorage.todos = JSON.stringify(data); 242 | resolve(true); 243 | }, 2000); 244 | }); 245 | }, 246 | get() { 247 | return new Promise(resolve => { 248 | setTimeout(() => { 249 | resolve(JSON.parse(localStorage.todos || '[]')); 250 | }, 1000); 251 | }); 252 | } 253 | } 254 | 255 | const GET_TODOS_PENDING = 'GET_TODOS_PENDING'; 256 | const GET_TODOS = 'GET_TODOS'; 257 | const GET_TODOS_ERROR = 'GET_TODOS_ERROR'; 258 | const ADD_TODO = 'ADD_TODO'; 259 | const SAVE_APP_PENDING = 'SAVE_APP_PENDING' 260 | const SAVE_APP = 'SAVE_APP'; 261 | const SAVE_APP_ERROR = 'SAVE_APP_ERROR'; 262 | const actions = { 263 | getTodos() { 264 | return dispatch => { 265 | dispatch({ type: GET_TODOS_PENDING }); 266 | api.get() 267 | .then(todos => dispatch({ type: GET_TODOS, payload: { todos } })) 268 | .catch(error => dispatch({ type: GET_TODOS_ERROR, payload: error, error: true })) 269 | ; 270 | ; 271 | } 272 | }, 273 | addTodo(todo) { 274 | return { type: ADD_TODO, payload: { todo } }; 275 | }, 276 | saveApp() { 277 | return (dispatch, getState) => { 278 | dispatch({ type: SAVE_APP_PENDING }); 279 | api.save(getState().todos.todos) 280 | .then(() => dispatch({ type: SAVE_APP })) 281 | .catch(error => dispatch({ type: SAVE_APP_ERROR, payload: error, error: true })) 282 | ; 283 | } 284 | }, 285 | }; 286 | const initialState = { 287 | isAppSynced: false, 288 | isFetching: false, 289 | fetchingError: null, 290 | isSaving: false, 291 | savingError: null, 292 | todos: [], 293 | }; 294 | const todosReducer = (state = initialState, action = {}) => { 295 | if (action.type === GET_TODOS_PENDING) { 296 | return { ...state, isFetching: true, fetchingError: null }; 297 | } 298 | if (action.type === GET_TODOS) { 299 | return { 300 | ...state, 301 | isAppSynced: true, 302 | isFetching: false, 303 | fetchingError: null, 304 | todos: action.payload.todos, 305 | }; 306 | } 307 | if (action.type === GET_TODOS_ERROR) { 308 | return { ...state, isFetching: false, fetchingError: action.payload.message }; 309 | } 310 | if (action.type === ADD_TODO) { 311 | return { ...state, isAppSynced: false, todos: state.todos.concat(action.payload.todo) }; 312 | } 313 | if (action.type === SAVE_APP_PENDING) { 314 | return { ...state, isSaving: true, savingError: null }; 315 | } 316 | if (action.type === SAVE_APP) { 317 | return { ...state, isAppSynced: true, isSaving: false, savingError: null }; 318 | } 319 | if (action === SAVE_APP_ERROR) { 320 | return { ...state, isSaving: false, savingError: action.payload.message } 321 | } 322 | return state; 323 | }; 324 | const reducer = combineReducers({ 325 | todos: todosReducer, 326 | }) 327 | const store = applyMiddleware(thunk, createLogger())(createStore)(reducer); 328 | 329 | class App extends Component { 330 | componentDidMount() { 331 | this.props.dispatch(actions.getTodos()); 332 | } 333 | render() { 334 | const { dispatch, todos, isAppSynced, isFetching, fetchingError, isSaving, savingError } = this.props; 335 | const { input } = this.refs; 336 | return
337 | {isAppSynced && 'app is synced up'} 338 | {isFetching && 'getting todos'} 339 | {fetchingError && 'there was an error getting todos: ' + fetchingError} 340 | {isSaving && 'saving todos'} 341 | {savingError && 'there was an error saving todos: ' + savingError} 342 | 343 | dispatch(actions.addTodo(input.value))} /> 344 | 345 |
346 |
{JSON.stringify(store.getState(), null, 2)}
347 |
; 348 | } 349 | } 350 | 351 | const ConnectedApp = connect(state => state.todos)(App); 352 | 353 | ReactDOM.render(, document.getElementById('root')); 354 | ``` 355 | 356 | As you can see there's a lot of async logic and state we don't want to have to deal with. 357 | This is 62 more LOC than the first version. Here's how you would do it in App 3 with 358 | `redux-await`: 359 | 360 | ## [App3 demo](http://kolodny.github.io/redux-await/app3/) 361 | ### App 3 362 | 363 | 364 | ```js 365 | import React, { Component } from 'react'; 366 | import ReactDOM from 'react-dom'; 367 | import { Provider } from 'react-redux'; 368 | import { applyMiddleware, createStore, combineReducers } from 'redux'; 369 | import thunk from 'redux-thunk'; 370 | import createLogger from 'redux-logger'; 371 | import { 372 | AWAIT_MARKER, 373 | createReducer, 374 | connect, 375 | reducer as awaitReducer, 376 | middleware as awaitMiddleware, 377 | } from 'redux-await'; 378 | 379 | // this not an API, this is a tribute 380 | const api = { 381 | save(data) { 382 | return new Promise(resolve => { 383 | setTimeout(() => { 384 | localStorage.todos = JSON.stringify(data); 385 | resolve(true); 386 | }, 2000); 387 | }); 388 | }, 389 | get() { 390 | return new Promise(resolve => { 391 | setTimeout(() => { 392 | resolve(JSON.parse(localStorage.todos || '[]')); 393 | }, 1000); 394 | }); 395 | } 396 | } 397 | 398 | const GET_TODOS = 'GET_TODOS'; 399 | const ADD_TODO = 'ADD_TODO'; 400 | const SAVE_APP = 'SAVE_APP'; 401 | const actions = { 402 | getTodos() { 403 | return { 404 | type: GET_TODOS, 405 | AWAIT_MARKER, 406 | payload: { 407 | todos: api.get(), 408 | }, 409 | }; 410 | }, 411 | addTodo(todo) { 412 | return { type: ADD_TODO, payload: { todo } }; 413 | }, 414 | saveApp() { 415 | return (dispatch, getState) => { 416 | dispatch({ 417 | type: SAVE_APP, 418 | AWAIT_MARKER, 419 | payload: { 420 | save: api.save(getState().todos.todos), 421 | }, 422 | }); 423 | } 424 | }, 425 | }; 426 | const initialState = { isAppSynced: false, todos: [] }; 427 | const todosReducer = (state = initialState, action = {}) => { 428 | if (action.type === GET_TODOS) { 429 | return { ...state, isAppSynced: true, todos: action.payload.todos }; 430 | } 431 | if (action.type === ADD_TODO) { 432 | return { ...state, isAppSynced: false, todos: state.todos.concat(action.payload.todo) }; 433 | } 434 | if (action.type === SAVE_APP) { 435 | return { ...state, isAppSynced: true }; 436 | } 437 | return state; 438 | }; 439 | const reducer = combineReducers({ 440 | todos: todosReducer, 441 | await: awaitReducer, 442 | }) 443 | 444 | const store = applyMiddleware(thunk, awaitMiddleware, createLogger())(createStore)(reducer); 445 | 446 | class App extends Component { 447 | componentDidMount() { 448 | this.props.dispatch(actions.getTodos()); 449 | } 450 | render() { 451 | const { dispatch, todos, isAppSynced, statuses, errors } = this.props; 452 | const { input } = this.refs; 453 | return
454 | {isAppSynced && 'app is synced up'} 455 | {statuses.todos === 'pending' && 'getting todos'} 456 | {statuses.todos === 'failure' && 'there was an error getting todos: ' + errors.todos.message} 457 | {statuses.save === 'pending' && 'saving todos'} 458 | {errors.save && 'there was an error saving todos: ' + errors.save.message} 459 | 460 | dispatch(actions.addTodo(input.value))} /> 461 | 462 |
463 |
{JSON.stringify(store.getState(), null, 2)}
464 |
; 465 | } 466 | } 467 | 468 | 469 | const ConnectedApp = connect(state => state.todos)(App); 470 | 471 | ReactDOM.render(, document.getElementById('root')); 472 | ``` 473 | 474 | This version is very easy to reason about, in fact you can completely ignore the fact that the app is async at all. The `todosReducer` didn't need to have a single line changed! 475 | Note that this is 107 LOC compared to app2's 125 LOC 476 | 477 | ## Some pitfalls to watch out for 478 | 479 | You must either use this modules `connect` or manually spread the `await` part of the tree over 480 | `mapStateToProps`, you can also choose to name it something other than `await` and spread that 481 | yourself too. 482 | 483 | `redux-await` will name the `statuses` and `errors` prop the same as the payload prop so try to be 484 | as descriptive as possible when naming payload props since any payload props collision will 485 | overwrite the `statuses`/`errors` value. For a CRUD app don't always name it something like 486 | `records` because when you're loading `users.records` the app will also think you're loading 487 | `todos.records` 488 | 489 | ## How it works: 490 | 491 | The middleware checks to see if the `AWAIT_MARKER` was set on the action 492 | and if it was then dispatches three events with a `[AWAIT_META_CONTAINER]` 493 | property on the meta property of the action. 494 | The reducer listens for actions with a meta of `[AWAIT_META_CONTAINER]` and 495 | when found will set the `await` property of the state accordingly. 496 | 497 | 498 | [npm-image]: https://img.shields.io/npm/v/redux-await.svg?style=flat-square 499 | [npm-url]: https://npmjs.org/package/redux-await 500 | [travis-image]: https://img.shields.io/travis/kolodny/redux-await.svg?style=flat-square 501 | [travis-url]: https://travis-ci.org/kolodny/redux-await 502 | [coveralls-image]: https://img.shields.io/coveralls/kolodny/redux-await.svg?style=flat-square 503 | [coveralls-url]: https://coveralls.io/r/kolodny/redux-await 504 | [downloads-image]: http://img.shields.io/npm/dm/redux-await.svg?style=flat-square 505 | [downloads-url]: https://npmjs.org/package/redux-await 506 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-await", 3 | "version": "5.0.1", 4 | "description": "Manage async redux actions sanely", 5 | "main": "./lib/index.js", 6 | "scripts": { 7 | "build": "babel src --out-dir lib", 8 | "prepublish": "npm run test && npm run test-react-e2e && npm run build", 9 | "test-cov": "nyc --reporter=lcov --reporter=text-lcov npm test && nyc report", 10 | "test-travis": "nyc npm test && nyc report --reporter=text-lcov | coveralls", 11 | "test-react-e2e": "npm run build && node test/test-redux/run-e2e-test.js", 12 | "test": "mocha --compilers js:babel-register 'test/*.js'" 13 | }, 14 | "author": "Moshe Kolodny", 15 | "license": "MIT", 16 | "devDependencies": { 17 | "babel-cli": "^6.3.17", 18 | "babel-preset-es2015": "^6.3.13", 19 | "babel-preset-stage-0": "^6.3.13", 20 | "babel-register": "^6.3.13", 21 | "coveralls": "^2.11.4", 22 | "expect": "^1.13.0", 23 | "fs-extra": "^0.26.2", 24 | "istanbul": "^0.4.1", 25 | "jsdom": "^5.6.1", 26 | "mkdirp": "^0.5.1", 27 | "mocha": "^2.3.4", 28 | "nyc": "git://github.com/bcoe/nyc.git#master", 29 | "react": "^0.14.3", 30 | "react-dom": "^0.14.3", 31 | "react-redux": "^4.0.0", 32 | "redux": "^3.0.4", 33 | "redux-thunk": "^1.0.0", 34 | "rimraf": "^2.4.4" 35 | }, 36 | "dependencies": {}, 37 | "peerDependencies": { 38 | "react-redux": "*" 39 | }, 40 | "repository": { 41 | "type": "git", 42 | "url": "git+https://github.com/kolodny/redux-await.git" 43 | }, 44 | "keywords": [ 45 | "redux", 46 | "async", 47 | "await" 48 | ], 49 | "bugs": { 50 | "url": "https://github.com/kolodny/redux-await/issues" 51 | }, 52 | "homepage": "https://github.com/kolodny/redux-await#readme" 53 | } 54 | -------------------------------------------------------------------------------- /src/connect.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | 3 | // can't really test this higher order function without overwriting the require cache 4 | /* istanbul ignore next */ 5 | export default (mapStateToProps, ...args) => { 6 | return connect((state, ownProps) => { 7 | const props = mapStateToProps(state, ownProps); 8 | const { statuses, errors } = state.await; 9 | return { ...props, statuses, errors }; 10 | }, ...args); 11 | } 12 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | export const AWAIT_MARKER = '@@redux-await/AWAIT_MARKER'; 2 | export const AWAIT_META_CONTAINER = '@@redux-await/AWAIT_META_CONTAINER'; 3 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { AWAIT_MARKER, AWAIT_META_CONTAINER } from './constants'; 2 | export { middleware, getPendingActionType, getFailedActionType } from './middleware'; 3 | export reducer from './reducer'; 4 | export connect from './connect'; 5 | -------------------------------------------------------------------------------- /src/middleware.js: -------------------------------------------------------------------------------- 1 | import { resolveProps, getNonPromiseProperties, getPromiseKeys, objectWithoutProperties } from './utils'; 2 | 3 | import { AWAIT_MARKER, AWAIT_META_CONTAINER } from './constants'; 4 | 5 | export const getPendingActionType = type => `${AWAIT_MARKER}/pending/${type}`; 6 | export const getFailedActionType = type => `${AWAIT_MARKER}/fail/${type}`; 7 | 8 | export const middleware = ({ dispatch }) => next => action => { 9 | const { payload, type, meta } = action; 10 | 11 | if (payload && action.AWAIT_MARKER === AWAIT_MARKER) { 12 | 13 | const promiseKeys = getPromiseKeys(payload); 14 | const scalarValues = getNonPromiseProperties(payload); 15 | const pendingMeta = { [AWAIT_META_CONTAINER]: { promiseKeys, scalarValues, status: 'pending' } }; 16 | const successMeta = { [AWAIT_META_CONTAINER]: { promiseKeys, scalarValues, status: 'success' } }; 17 | const failureMeta = { [AWAIT_META_CONTAINER]: { promiseKeys, scalarValues, status: 'failure' } }; 18 | 19 | const newAction = objectWithoutProperties(action, ['type', 'payload', 'AWAIT_MARKER']); 20 | 21 | dispatch({ 22 | ...newAction, 23 | type: getPendingActionType(type), 24 | meta: { ...meta, ...pendingMeta, type }, 25 | }); 26 | 27 | const successCallback = payload => { 28 | dispatch({ 29 | ...newAction, 30 | type, 31 | payload, 32 | meta: { ...meta, ...successMeta }, 33 | }); 34 | }; 35 | 36 | const failureCallback = error => { 37 | dispatch({ 38 | ...newAction, 39 | type: getFailedActionType(type), 40 | payload: error, 41 | meta: { ...meta, ...failureMeta, type }, 42 | }); 43 | } 44 | 45 | resolveProps(payload).then(successCallback, failureCallback); 46 | 47 | } else { 48 | next(action); 49 | } 50 | }; 51 | -------------------------------------------------------------------------------- /src/reducer.js: -------------------------------------------------------------------------------- 1 | import { AWAIT_META_CONTAINER, AWAIT_INFO_CONTAINER } from './constants'; 2 | import { getPromiseKeys } from './utils'; 3 | 4 | const initialState = { statuses: {}, errors: {} }; 5 | export default (state = initialState, action = {}) => { 6 | if (action.meta && action.meta[AWAIT_META_CONTAINER]) { 7 | const awaitMeta = action.meta[AWAIT_META_CONTAINER]; 8 | const { status } = awaitMeta; 9 | 10 | const statuses = { ...state.statuses }; 11 | const errors = { ...state.errors }; 12 | awaitMeta.promiseKeys.forEach(prop => { 13 | statuses[prop] = status; 14 | if (status === 'failure') { 15 | errors[prop] = action.payload; 16 | } else { 17 | // only unset errors prop if previously set 18 | if (errors[prop]) { 19 | errors[prop] = null; 20 | } 21 | } 22 | }); 23 | return { statuses, errors }; 24 | } 25 | return state; 26 | }; 27 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | export const isPromise = obj => obj && typeof obj.then === 'function'; 2 | export const getPromiseKeys = obj => Object.keys(obj).filter(key => isPromise(obj[key])); 3 | 4 | export const resolveProps = obj => { 5 | const props = Object.keys(obj); 6 | const values = props.map(prop => obj[prop]); 7 | 8 | return Promise.all(values).then(resolvedArray => { 9 | return props.reduce((acc, prop, index) => { 10 | acc[prop] = resolvedArray[index]; 11 | return acc; 12 | }, {}); 13 | }); 14 | }; 15 | 16 | export const getNonPromiseProperties = obj => { 17 | return Object.keys(obj).filter(key => !isPromise(obj[key])).reduce((acc, key) => { 18 | acc[key] = obj[key]; 19 | return acc; 20 | }, {}); 21 | }; 22 | 23 | // this is taken from babel's source, no need to test 24 | /* istanbul ignore next */ 25 | export const objectWithoutProperties = (obj, keys) => { 26 | var target = {}; 27 | for (var i in obj) { 28 | if (keys.indexOf(i) >= 0) continue; 29 | 30 | if (!Object.prototype.hasOwnProperty.call(obj, i)) continue; 31 | target[i] = obj[i]; 32 | } 33 | return target; 34 | } 35 | -------------------------------------------------------------------------------- /test/redux-e2e.js: -------------------------------------------------------------------------------- 1 | import expect from 'expect'; 2 | import { createStore, applyMiddleware, combineReducers } from 'redux'; 3 | import { 4 | AWAIT_MARKER, 5 | middleware, 6 | reducer as awaitReducer, 7 | getPendingActionType, 8 | } from '../src'; 9 | 10 | describe('redux-await', () => { 11 | 12 | it("works with redux", done => { 13 | const actions = []; 14 | const createStoreWithMiddleware = applyMiddleware(middleware)(createStore); 15 | const appReducer = (state = {}, action) => { 16 | actions.push(action); 17 | if (action.type === getPendingActionType('TESTING')) { return { ...state, wasPending: true } }; 18 | if (action.type === 'TESTING') { return { ...state, wasTested: true, soon: action.payload.soon + '!' } } 19 | return state; 20 | }; 21 | const reducers = combineReducers({ 22 | app: appReducer, 23 | await: awaitReducer, 24 | }) 25 | 26 | const store = createStoreWithMiddleware(reducers); 27 | const states = []; 28 | store.subscribe(() => { 29 | states.push(store.getState()); 30 | if (states.length === 6) { 31 | try { 32 | expect(states[0].app).toEqual({wasPending: true}); 33 | expect(states[0].await.statuses.soon).toEqual('pending'); 34 | expect(states[0].await.statuses.heyo).toEqual('pending'); 35 | 36 | // doesn't overwrite old statuses 37 | expect(states[1].await.statuses.heyo).toEqual('pending'); 38 | 39 | expect(states[2].app).toEqual({ wasPending: true, wasTested: true, soon: 'v!' }); 40 | expect(states[2].await.statuses.soon).toEqual('success'); 41 | expect(states[2].await.statuses.heyo).toEqual('success'); 42 | 43 | expect(states[3].await.statuses.soon).toEqual('failure'); 44 | expect(states[3].await.errors.soon.message).toEqual('no!'); 45 | 46 | expect(states[4].await.statuses.soon).toEqual('pending'); 47 | expect(states[4].await.errors.soon).toBeFalsy(); 48 | 49 | expect(states[5].await.statuses.soon).toEqual('success'); 50 | 51 | // make sure we don't overwrite action.meta 52 | expect(actions[actions.length - 1].meta.so).toEqual('meta'); 53 | done(); 54 | } catch (e) { 55 | done(e); 56 | } 57 | } 58 | }); 59 | const generateRejection = () => new Promise((_, reject) => setTimeout(() => reject(new Error('no!')), 15)); 60 | 61 | // smiley face; 62 | store.dispatch({ type: 'TESTING', AWAIT_MARKER, payload: { soon: Promise.resolve('v'), heyo: Promise.resolve('heyo'), ignore: 123 } }); 63 | store.dispatch({ type: 'TESTING', AWAIT_MARKER, payload: { soon: generateRejection(), ignore: 123 } }); 64 | setTimeout(() => store.dispatch({ type: 'TESTING', AWAIT_MARKER, meta: {so: 'meta'}, payload: { soon: Promise.resolve('v'), heyo: Promise.resolve('heyo'), ignore: 123 } }), 20) 65 | 66 | }); 67 | 68 | }); 69 | -------------------------------------------------------------------------------- /test/test-redux/code-to-run.js: -------------------------------------------------------------------------------- 1 | import jsdom from 'jsdom'; 2 | global.document = jsdom.jsdom('
'); 3 | global.window = document.defaultView; 4 | global.navigator = { 5 | userAgent: 'node.js' 6 | }; 7 | 8 | import expect from 'expect'; 9 | import React, { Component } from 'react'; 10 | import ReactDOM from 'react-dom'; 11 | import { Provider } from 'react-redux'; 12 | import { applyMiddleware, createStore, combineReducers } from 'redux'; 13 | import thunk from 'redux-thunk'; 14 | import { 15 | AWAIT_MARKER, 16 | AWAIT_META_CONTAINER, 17 | createReducer, 18 | getPendingActionType, 19 | getFailedActionType, 20 | connect, 21 | reducer as awaitReducer, 22 | middleware as awaitMiddleware, 23 | } from 'redux-await'; 24 | 25 | describe('redux-await', () => { 26 | it('passes an intense react-redux e2e test suite ;)', done => { 27 | 28 | const savedActions = [], savedStates = [], savedProps = []; 29 | 30 | const counter = (state = 0, action = {}) => { 31 | savedActions.push(action); 32 | if (action.type === 'INC') return state + 1; 33 | return state; 34 | }; 35 | const reducer = combineReducers({ 36 | counter, 37 | await: awaitReducer, 38 | }); 39 | const store = applyMiddleware(awaitMiddleware)(createStore)(reducer); 40 | store.subscribe(() => { 41 | savedStates.push(store.getState()); 42 | if (savedActions.length > 7) { 43 | setTimeout(() => { 44 | checkThatEverythingWentOk({ savedActions, savedStates, savedProps }, done); 45 | }, 100) 46 | } 47 | }); 48 | 49 | class App extends Component { 50 | render() { 51 | savedProps.push(this.props) 52 | return null; 53 | } 54 | } 55 | const ConnectedApp = connect(state => ({ counter: state.counter }) )(App); 56 | 57 | ReactDOM.render(React.createElement(Provider, { store }, React.createElement(ConnectedApp)), document.getElementById('root')); 58 | store.dispatch({ type: 'IGNORE_ME', AWAIT_MARKER, payload: { soon: Promise.resolve('v'), heyo: Promise.resolve('heyo'), ignore: 123 } }); 59 | store.dispatch({ type: 'IGNORE_ME', AWAIT_MARKER, payload: { soon: new Promise((_, rej) => setTimeout(() => rej(new Error('no!')), 100)) } }); 60 | store.dispatch({ type: 'INC' }); 61 | 62 | }); 63 | }); 64 | 65 | 66 | function checkThatEverythingWentOk({ savedActions, savedStates, savedProps }, done) { 67 | try { 68 | const dispatch = savedProps[0].dispatch; 69 | savedActions.shift(); 70 | savedActions.shift(); 71 | savedActions.shift(); 72 | 73 | expect(savedActions[0].type).toEqual(getPendingActionType('IGNORE_ME')); 74 | expect(savedActions[0].meta[AWAIT_META_CONTAINER].promiseKeys).toEqual(['soon', 'heyo']); 75 | expect(savedActions[0].meta[AWAIT_META_CONTAINER].scalarValues).toEqual({ignore: 123}); 76 | 77 | expect(savedActions[1].type).toEqual(getPendingActionType('IGNORE_ME')); 78 | expect(savedActions[1].meta[AWAIT_META_CONTAINER].promiseKeys).toEqual(['soon']); 79 | expect(savedActions[1].meta[AWAIT_META_CONTAINER].scalarValues).toEqual({}); 80 | 81 | expect(savedActions[2].type).toEqual('INC'); 82 | 83 | expect(savedActions[3].type).toEqual('IGNORE_ME'); 84 | expect(savedActions[3].payload).toEqual({ soon: "v", heyo: "heyo", ignore: 123 }); 85 | 86 | expect(savedActions[4].type).toEqual(getFailedActionType('IGNORE_ME')); 87 | expect(savedActions[4].payload).toBeAn(Error); 88 | 89 | expect(savedStates[0]).toEqual({counter: 0, await: { errors: {}, statuses: { 90 | soon: 'pending', heyo: 'pending' 91 | }}}); 92 | 93 | expect(savedStates[1]).toEqual(savedStates[0]); 94 | expect(savedStates[2]).toEqual({ ...savedStates[0], counter: 1 }); 95 | 96 | expect(savedStates[3]).toEqual({counter: 1, await: { errors: {}, statuses: { 97 | soon: 'success', heyo: 'success' 98 | }}}); 99 | 100 | expect(savedStates[4]).toEqual({counter: 1, await: { errors: { soon: new Error() }, statuses: { 101 | soon: 'failure', heyo: 'success' 102 | }}}); 103 | 104 | expect(savedProps[0]).toEqual({ dispatch, counter: 0, statuses: {}, errors: {} }); 105 | expect(savedProps[1]).toEqual({ dispatch, counter: 0, statuses: { soon: 'pending', heyo: 'pending' }, errors: {} }); 106 | expect(savedProps[2]).toEqual({ dispatch, counter: 0, statuses: { soon: 'pending', heyo: 'pending' }, errors: {} }); 107 | expect(savedProps[3]).toEqual({ dispatch, counter: 1, statuses: { soon: 'pending', heyo: 'pending' }, errors: {} }); 108 | expect(savedProps[4]).toEqual({ dispatch, counter: 1, statuses: { soon: 'success', heyo: 'success' }, errors: {} }); 109 | expect(savedProps[5]).toEqual({ dispatch, counter: 1, statuses: { soon: 'failure', heyo: 'success' }, errors: { 110 | soon: new Error() 111 | } }); 112 | 113 | done(); 114 | } catch (e) { done(e) } 115 | } 116 | -------------------------------------------------------------------------------- /test/test-redux/project/index.js: -------------------------------------------------------------------------------- 1 | import jsdom from 'jsdom'; 2 | global.document = jsdom.jsdom('
'); 3 | global.window = document.defaultView; 4 | global.navigator = { 5 | userAgent: 'node.js' 6 | }; 7 | 8 | import expect from 'expect'; 9 | import React, { Component } from 'react'; 10 | import ReactDOM from 'react-dom'; 11 | import { Provider } from 'react-redux'; 12 | import { applyMiddleware, createStore, combineReducers } from 'redux'; 13 | import thunk from 'redux-thunk'; 14 | import { 15 | AWAIT_MARKER, 16 | AWAIT_META_CONTAINER, 17 | createReducer, 18 | getPendingActionType, 19 | getFailedActionType, 20 | connect, 21 | reducer as awaitReducer, 22 | middleware as awaitMiddleware, 23 | } from 'redux-await'; 24 | 25 | describe('redux-await', () => { 26 | it('passes an intense react-redux e2e test suite ;)', done => { 27 | 28 | const savedActions = [], savedStates = [], savedProps = []; 29 | 30 | const counter = (state = 0, action = {}) => { 31 | savedActions.push(action); 32 | if (action.type === 'INC') return state + 1; 33 | return state; 34 | }; 35 | const reducer = combineReducers({ 36 | counter, 37 | await: awaitReducer, 38 | }); 39 | const store = applyMiddleware(awaitMiddleware)(createStore)(reducer); 40 | store.subscribe(() => { 41 | savedStates.push(store.getState()); 42 | if (savedActions.length > 7) { 43 | setTimeout(() => { 44 | checkThatEverythingWentOk({ savedActions, savedStates, savedProps }, done); 45 | }, 100) 46 | } 47 | }); 48 | 49 | class App extends Component { 50 | render() { 51 | savedProps.push(this.props) 52 | return null; 53 | } 54 | } 55 | const ConnectedApp = connect(state => ({ counter: state.counter }) )(App); 56 | 57 | ReactDOM.render(React.createElement(Provider, { store }, React.createElement(ConnectedApp)), document.getElementById('root')); 58 | store.dispatch({ type: 'IGNORE_ME', AWAIT_MARKER, payload: { soon: Promise.resolve('v'), heyo: Promise.resolve('heyo'), ignore: 123 } }); 59 | store.dispatch({ type: 'IGNORE_ME', AWAIT_MARKER, payload: { soon: new Promise((_, rej) => setTimeout(() => rej(new Error('no!')), 100)) } }); 60 | store.dispatch({ type: 'INC' }); 61 | 62 | }); 63 | }); 64 | 65 | 66 | function checkThatEverythingWentOk({ savedActions, savedStates, savedProps }, done) { 67 | try { 68 | const dispatch = savedProps[0].dispatch; 69 | savedActions.shift(); 70 | savedActions.shift(); 71 | savedActions.shift(); 72 | 73 | expect(savedActions[0].type).toEqual(getPendingActionType('IGNORE_ME')); 74 | expect(savedActions[0].meta[AWAIT_META_CONTAINER].promiseKeys).toEqual(['soon', 'heyo']); 75 | expect(savedActions[0].meta[AWAIT_META_CONTAINER].scalarValues).toEqual({ignore: 123}); 76 | 77 | expect(savedActions[1].type).toEqual(getPendingActionType('IGNORE_ME')); 78 | expect(savedActions[1].meta[AWAIT_META_CONTAINER].promiseKeys).toEqual(['soon']); 79 | expect(savedActions[1].meta[AWAIT_META_CONTAINER].scalarValues).toEqual({}); 80 | 81 | expect(savedActions[2].type).toEqual('INC'); 82 | 83 | expect(savedActions[3].type).toEqual('IGNORE_ME'); 84 | expect(savedActions[3].payload).toEqual({ soon: "v", heyo: "heyo", ignore: 123 }); 85 | 86 | expect(savedActions[4].type).toEqual(getFailedActionType('IGNORE_ME')); 87 | expect(savedActions[4].payload).toBeAn(Error); 88 | 89 | expect(savedStates[0]).toEqual({counter: 0, await: { errors: {}, statuses: { 90 | soon: 'pending', heyo: 'pending' 91 | }}}); 92 | 93 | expect(savedStates[1]).toEqual(savedStates[0]); 94 | expect(savedStates[2]).toEqual({ ...savedStates[0], counter: 1 }); 95 | 96 | expect(savedStates[3]).toEqual({counter: 1, await: { errors: {}, statuses: { 97 | soon: 'success', heyo: 'success' 98 | }}}); 99 | 100 | expect(savedStates[4]).toEqual({counter: 1, await: { errors: { soon: new Error() }, statuses: { 101 | soon: 'failure', heyo: 'success' 102 | }}}); 103 | 104 | expect(savedProps[0]).toEqual({ dispatch, counter: 0, statuses: {}, errors: {} }); 105 | expect(savedProps[1]).toEqual({ dispatch, counter: 0, statuses: { soon: 'pending', heyo: 'pending' }, errors: {} }); 106 | expect(savedProps[2]).toEqual({ dispatch, counter: 0, statuses: { soon: 'pending', heyo: 'pending' }, errors: {} }); 107 | expect(savedProps[3]).toEqual({ dispatch, counter: 1, statuses: { soon: 'pending', heyo: 'pending' }, errors: {} }); 108 | expect(savedProps[4]).toEqual({ dispatch, counter: 1, statuses: { soon: 'success', heyo: 'success' }, errors: {} }); 109 | expect(savedProps[5]).toEqual({ dispatch, counter: 1, statuses: { soon: 'failure', heyo: 'success' }, errors: { 110 | soon: new Error() 111 | } }); 112 | 113 | done(); 114 | } catch (e) { done(e) } 115 | } 116 | -------------------------------------------------------------------------------- /test/test-redux/run-e2e-test.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var spawn = require('child_process').spawn; 3 | var fs = require('fs-extra'); 4 | var rimraf = require('rimraf').sync; 5 | var mkdirp = require('mkdirp').sync; 6 | 7 | var codeToRunPath = path.join(__dirname, 'code-to-run.js'); 8 | var projectPath = path.join(__dirname, 'project'); 9 | var nodeModulesReduxAwaitPath = path.join(projectPath, 'node_modules', 'redux-await'); 10 | var indexFileLocation = path.join(projectPath, 'index.js'); 11 | 12 | var reduxAwaitPath = path.join(__dirname, '..', '..'); 13 | var reduxAwaitLibPath = path.join(reduxAwaitPath, 'lib'); 14 | var _mochaPath = path.join(reduxAwaitPath, 'node_modules', '.bin', '_mocha'); 15 | 16 | rimraf(projectPath); 17 | mkdirp(nodeModulesReduxAwaitPath); 18 | 19 | fs.copySync(reduxAwaitLibPath, nodeModulesReduxAwaitPath); 20 | fs.copySync(codeToRunPath, indexFileLocation) 21 | 22 | spawn(_mochaPath, ['--require', 'babel-register', indexFileLocation], {stdio: 'inherit'}).on('exit', process.exit); 23 | -------------------------------------------------------------------------------- /test/units.js: -------------------------------------------------------------------------------- 1 | import expect from 'expect'; 2 | import { 3 | AWAIT_MARKER, 4 | AWAIT_META_CONTAINER, 5 | middleware, 6 | reducer, 7 | connect, 8 | getPendingActionType, 9 | } from '../src'; 10 | 11 | describe('redux-await', () => { 12 | it('exports AWAIT_MARKER', () => { 13 | expect(AWAIT_MARKER).toBeTruthy(); 14 | }); 15 | it('exports AWAIT_META_CONTAINER', () => { 16 | expect(AWAIT_META_CONTAINER).toBeTruthy(); 17 | }); 18 | it('exports middleware', () => { 19 | expect(middleware).toBeTruthy(); 20 | }); 21 | it('exports reducer', () => { 22 | expect(reducer).toBeTruthy(); 23 | }); 24 | it('exports connect', () => { 25 | expect(connect).toBeTruthy(); 26 | }); 27 | 28 | describe('reducer', () => { 29 | it('handles initial actions', () => { 30 | expect( reducer(undefined, {}) ).toEqual({ statuses: {}, errors: {} }); 31 | }); 32 | it('handles AWAIT_META_CONTAINER pending actions', () => { 33 | const action = { 34 | meta: { 35 | [AWAIT_META_CONTAINER]: { 36 | status: 'pending', 37 | promiseKeys: ['a'], 38 | } 39 | } 40 | }; 41 | expect( reducer({ statuses: {}, errors: {} }, action )).toEqual( 42 | { statuses: { a: 'pending' }, errors: {} } 43 | ); 44 | }); 45 | 46 | it('handles AWAIT_META_CONTAINER success actions', () => { 47 | const action = { 48 | meta: { 49 | [AWAIT_META_CONTAINER]: { 50 | status: 'success', 51 | promiseKeys: ['a'], 52 | } 53 | } 54 | }; 55 | expect( reducer({ statuses: { a: 'pending' }, errors: {} }, action )).toEqual( 56 | { statuses: { a: 'success' }, errors: {} } 57 | ); 58 | }); 59 | 60 | 61 | it('handles AWAIT_META_CONTAINER failure actions', () => { 62 | const action = { 63 | payload: new Error('fail!'), 64 | meta: { 65 | [AWAIT_META_CONTAINER]: { 66 | status: 'failure', 67 | promiseKeys: ['a'], 68 | } 69 | } 70 | }; 71 | expect( reducer({ statuses: { a: 'pending' }, errors: {} }, action )).toEqual( 72 | { statuses: { a: 'failure' }, errors: { a: new Error('fail!') } } 73 | ); 74 | }); 75 | 76 | 77 | }); 78 | 79 | 80 | }); 81 | --------------------------------------------------------------------------------