redux-await
Manage async redux actions sanely
Breaking Changes!!
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
Install
npm install --save redux-await
Usage
This module exposes a middleware, reducer, and connector to take care of async state in a redux app. You'll need to:
-
Apply the middleware:
import { middleware as awaitMiddleware } from 'redux-await'; let createStoreWithMiddleware = applyMiddleware( awaitMiddleware )(createStore);
-
Install the reducer into the
await
path of yourcombineReducers
import reducers from './reducers'; // old code // const store = applyMiddleware(thunk)(createStore)(reducers); // new code import { reducer as awaitReducer } from 'redux-await'; const store = applyMiddleware(thunk, awaitMiddleware)(createStore)({ ...reducers, await: awaitReducer, });
-
Use the
connect
function from this module and notreact-redux
's// old code // import { connect } from 'react-redux'; // new code import { connect } from 'redux-await'; class FooPage extends Component { render() { /* ... */ } } export default connect(state => state.foo)(FooPage)
Now your action payloads can contain promises, you just need to add AWAIT_MARKER
to the
action like this:
// old code
//export const getTodos = () => ({
// type: GET_TODOS,
// payload: {
// loadedTodos: localStorage.todos,
// },
//});
//export const addTodo = todo => ({
// type: ADD_TODO,
// payload: {
// savedTodo: todo,
// },
//});
// new code
import { AWAIT_MARKER } from 'redux-await';
export const getTodos = () => ({
type: GET_TODOS,
AWAIT_MARKER,
payload: {
loadedTodos: api.getTodos(), // returns promise
},
});
export const addTodo = todo => ({
type: ADD_TODO,
AWAIT_MARKER,
payload: {
savedTodo: api.saveTodo(todo), // returns promise
},
});
Now your containers barely need to change:
class Container extends Component {
render() {
const { todos, statuses, errors } = this.props;
// old code
//return <div>
// <MyList data={todos} />
//</div>;
// new code
return <div>
{ statuses.loadedTodos === 'pending' && <div>Loading...</div> }
{ statuses.loadedTodos === 'success' && <MyList data={loadedTodos} /> }
{ statuses.loadedTodos.status === 'failure' && <div>Oops: {errors.loadedTodos.message}</div> }
{ statuses.savedTodo === 'pending' && <div>Saving new savedTodo</div> }
{ statuses.savedTodo === 'failure' && <div>There was an error saving</div> }
</div>;
}
}
//old code
// import { connect } from 'react-redux';
// new code
import { connect } from 'redux-await'; // it just spreads state.await on props
export default connect(state => state.todos)(Container)
Why
Redux is mostly concerned about how to manage state in a synchronous setting. Async apps create challenges like keeping track of the async status and dealing with async errors. While it is possible to build an app this way using redux-thunk and/or redux-promise it tends to bloat the app and it makes unit testing needlessly verbose
redux-await
tries to solve all of these problems by keeping track of async payloads by means
of a middleware and a reducer keeping track of payload properties statuses. Let's walk
through the development of a TODO app (App 1) that starts without any async and then needs to
start converting action from sync to async. We'll first try only using redux-thunk
to solve
this (App 2), and then see how to solve this with redux-await
(App 3)
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:
App1 demo
App 1
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import { Provider, connect } from 'react-redux';
import { applyMiddleware, createStore, combineReducers } from 'redux';
import thunk from 'redux-thunk';
import createLogger from 'redux-logger';
const GET_TODOS = 'GET_TODOS';
const ADD_TODO = 'ADD_TODO';
const SAVE_APP = 'SAVE_APP';
const actions = {
getTodos() {
const todos = JSON.parse(localStorage.todos || '[]');
return { type: GET_TODOS, payload: { todos } };
},
addTodo(todo) {
return { type: ADD_TODO, payload: { todo } };
},
saveApp() {
return (dispatch, getState) => {
localStorage.todos = JSON.stringify(getState().todos.todos);
dispatch({ type: SAVE_APP });
}
},
};
const initialState = { isAppSynced: false, todos: [] };
const todosReducer = (state = initialState, action = {}) => {
if (action.type === GET_TODOS) {
return { ...state, isAppSynced: true, todos: action.payload.todos };
}
if (action.type === ADD_TODO) {
return { ...state, isAppSynced: false, todos: state.todos.concat(action.payload.todo) };
}
if (action.type === SAVE_APP) {
return { ...state, isAppSynced: true };
}
return state;
};
const reducer = combineReducers({
todos: todosReducer,
})
const store = applyMiddleware(thunk, createLogger())(createStore)(reducer);
class App extends Component {
componentDidMount() {
this.props.dispatch(actions.getTodos());
}
render() {
const { dispatch, todos, isAppSynced } = this.props;
const { input } = this.refs;
return <div>
{isAppSynced && 'app is synced up'}
<ul>{todos.map(todo => <li>{todo}</li>)}</ul>
<input ref="input" type="text" onBlur={() => dispatch(actions.addTodo(input.value))} />
<button onClick={() => dispatch(actions.saveApp())}>Sync</button>
<br />
<pre>{JSON.stringify(store.getState(), null, 2)}</pre>
</div>;
}
}
const ConnectedApp = connect(state => state.todos)(App);
ReactDOM.render(<Provider store={store}><ConnectedApp /></Provider>, document.getElementById('root'));
Looks cool (it's a POC so it's purposely minimal), but let's say you want to start using an API which is async to store the state, now your app will look something like App 2:
App2 demo
App 2
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import { Provider, connect } from 'react-redux';
import { applyMiddleware, createStore, combineReducers } from 'redux';
import thunk from 'redux-thunk';
import createLogger from 'redux-logger';
// this not an API, this is a tribute
const api = {
save(data) {
return new Promise(resolve => {
setTimeout(() => {
localStorage.todos = JSON.stringify(data);
resolve(true);
}, 2000);
});
},
get() {
return new Promise(resolve => {
setTimeout(() => {
resolve(JSON.parse(localStorage.todos || '[]'));
}, 1000);
});
}
}
const GET_TODOS_PENDING = 'GET_TODOS_PENDING';
const GET_TODOS = 'GET_TODOS';
const GET_TODOS_ERROR = 'GET_TODOS_ERROR';
const ADD_TODO = 'ADD_TODO';
const SAVE_APP_PENDING = 'SAVE_APP_PENDING'
const SAVE_APP = 'SAVE_APP';
const SAVE_APP_ERROR = 'SAVE_APP_ERROR';
const actions = {
getTodos() {
return dispatch => {
dispatch({ type: GET_TODOS_PENDING });
api.get()
.then(todos => dispatch({ type: GET_TODOS, payload: { todos } }))
.catch(error => dispatch({ type: GET_TODOS_ERROR, payload: error, error: true }))
;
;
}
},
addTodo(todo) {
return { type: ADD_TODO, payload: { todo } };
},
saveApp() {
return (dispatch, getState) => {
dispatch({ type: SAVE_APP_PENDING });
api.save(getState().todos.todos)
.then(() => dispatch({ type: SAVE_APP }))
.catch(error => dispatch({ type: SAVE_APP_ERROR, payload: error, error: true }))
;
}
},
};
const initialState = {
isAppSynced: false,
isFetching: false,
fetchingError: null,
isSaving: false,
savingError: null,
todos: [],
};
const todosReducer = (state = initialState, action = {}) => {
if (action.type === GET_TODOS_PENDING) {
return { ...state, isFetching: true, fetchingError: null };
}
if (action.type === GET_TODOS) {
return {
...state,
isAppSynced: true,
isFetching: false,
fetchingError: null,
todos: action.payload.todos,
};
}
if (action.type === GET_TODOS_ERROR) {
return { ...state, isFetching: false, fetchingError: action.payload.message };
}
if (action.type === ADD_TODO) {
return { ...state, isAppSynced: false, todos: state.todos.concat(action.payload.todo) };
}
if (action.type === SAVE_APP_PENDING) {
return { ...state, isSaving: true, savingError: null };
}
if (action.type === SAVE_APP) {
return { ...state, isAppSynced: true, isSaving: false, savingError: null };
}
if (action === SAVE_APP_ERROR) {
return { ...state, isSaving: false, savingError: action.payload.message }
}
return state;
};
const reducer = combineReducers({
todos: todosReducer,
})
const store = applyMiddleware(thunk, createLogger())(createStore)(reducer);
class App extends Component {
componentDidMount() {
this.props.dispatch(actions.getTodos());
}
render() {
const { dispatch, todos, isAppSynced, isFetching, fetchingError, isSaving, savingError } = this.props;
const { input } = this.refs;
return <div>
{isAppSynced && 'app is synced up'}
{isFetching && 'getting todos'}
{fetchingError && 'there was an error getting todos: ' + fetchingError}
{isSaving && 'saving todos'}
{savingError && 'there was an error saving todos: ' + savingError}
<ul>{todos.map(todo => <li>{todo}</li>)}</ul>
<input ref="input" type="text" onBlur={() => dispatch(actions.addTodo(input.value))} />
<button onClick={() => dispatch(actions.saveApp())}>Sync</button>
<br />
<pre>{JSON.stringify(store.getState(), null, 2)}</pre>
</div>;
}
}
const ConnectedApp = connect(state => state.todos)(App);
ReactDOM.render(<Provider store={store}><ConnectedApp /></Provider>, document.getElementById('root'));
As you can see there's a lot of async logic and state we don't want to have to deal with.
This is 62 more LOC than the first version. Here's how you would do it in App 3 with
redux-await
:
App3 demo
App 3
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { applyMiddleware, createStore, combineReducers } from 'redux';
import thunk from 'redux-thunk';
import createLogger from 'redux-logger';
import {
AWAIT_MARKER,
createReducer,
connect,
reducer as awaitReducer,
middleware as awaitMiddleware,
} from 'redux-await';
// this not an API, this is a tribute
const api = {
save(data) {
return new Promise(resolve => {
setTimeout(() => {
localStorage.todos = JSON.stringify(data);
resolve(true);
}, 2000);
});
},
get() {
return new Promise(resolve => {
setTimeout(() => {
resolve(JSON.parse(localStorage.todos || '[]'));
}, 1000);
});
}
}
const GET_TODOS = 'GET_TODOS';
const ADD_TODO = 'ADD_TODO';
const SAVE_APP = 'SAVE_APP';
const actions = {
getTodos() {
return {
type: GET_TODOS,
AWAIT_MARKER,
payload: {
todos: api.get(),
},
};
},
addTodo(todo) {
return { type: ADD_TODO, payload: { todo } };
},
saveApp() {
return (dispatch, getState) => {
dispatch({
type: SAVE_APP,
AWAIT_MARKER,
payload: {
save: api.save(getState().todos.todos),
},
});
}
},
};
const initialState = { isAppSynced: false, todos: [] };
const todosReducer = (state = initialState, action = {}) => {
if (action.type === GET_TODOS) {
return { ...state, isAppSynced: true, todos: action.payload.todos };
}
if (action.type === ADD_TODO) {
return { ...state, isAppSynced: false, todos: state.todos.concat(action.payload.todo) };
}
if (action.type === SAVE_APP) {
return { ...state, isAppSynced: true };
}
return state;
};
const reducer = combineReducers({
todos: todosReducer,
await: awaitReducer,
})
const store = applyMiddleware(thunk, awaitMiddleware, createLogger())(createStore)(reducer);
class App extends Component {
componentDidMount() {
this.props.dispatch(actions.getTodos());
}
render() {
const { dispatch, todos, isAppSynced, statuses, errors } = this.props;
const { input } = this.refs;
return <div>
{isAppSynced && 'app is synced up'}
{statuses.todos === 'pending' && 'getting todos'}
{statuses.todos === 'failure' && 'there was an error getting todos: ' + errors.todos.message}
{statuses.save === 'pending' && 'saving todos'}
{errors.save && 'there was an error saving todos: ' + errors.save.message}
<ul>{todos.map(todo => <li>{todo}</li>)}</ul>
<input ref="input" type="text" onBlur={() => dispatch(actions.addTodo(input.value))} />
<button onClick={() => dispatch(actions.saveApp())}>Sync</button>
<br />
<pre>{JSON.stringify(store.getState(), null, 2)}</pre>
</div>;
}
}
const ConnectedApp = connect(state => state.todos)(App);
ReactDOM.render(<Provider store={store}><ConnectedApp /></Provider>, document.getElementById('root'));
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!
Note that this is 107 LOC compared to app2's 125 LOC
Some pitfalls to watch out for
You must either use this modules connect
or manually spread the await
part of the tree over
mapStateToProps
, you can also choose to name it something other than await
and spread that
yourself too.
redux-await
will name the statuses
and errors
prop the same as the payload prop so try to be
as descriptive as possible when naming payload props since any payload props collision will
overwrite the statuses
/errors
value. For a CRUD app don't always name it something like
records
because when you're loading users.records
the app will also think you're loading
todos.records
How it works:
The middleware checks to see if the AWAIT_MARKER
was set on the action
and if it was then dispatches three events with a [AWAIT_META_CONTAINER]
property on the meta property of the action.
The reducer listens for actions with a meta of [AWAIT_META_CONTAINER]
and
when found will set the await
property of the state accordingly.