(this is adapted from the redux-happy-async project I made earlier this year, but I think simplified in an easier-to-understand way)
Managing async action state in Redux is a very, very common topic of discussion. The classical way to do it is to create three action types (for start, error, and success), and then create boilerplate to set loading/error states for each action inside a reducer. This boilerplate adds up fast if you're creating an app with lots of async actions!
This library abstracts away this boilerplate with a ✨ magic ✨ requests
reducer that will handle storing and retrieving this state across your application.
A "request" is simply a tiny little state machine that lives in a requests
reducer. It's keyed off of a name (usually an action type) and, optionally, a unique key to help track multiple requests of the same type.
By keeping a request in your Redux state, you can easily trigger a request (through an action creator) in a component and react to the loading/error states of that request in a different component. It is also easy to prevent multiple, identical requests from being in-flight at once.
The request holds the following state:
status
: This is eitherREQUEST_IDLE
,REQUEST_PENDING
REQUEST_SUCCESS
, orREQUEST_ERROR
.error
: This is an error value supplied by the action creator when the error state is entered.key
: This is the unique key of the request, if supplied.
Within a component, you can fetch a request from the state:
import {getRequest} from 'LIB_NAME';
function mapStateToProps(state, props) {
return {
// gets the request with the unique key actionTypes.getUser -> props.userId
request: getRequest(state, [actionTypes.getUser, props.userId]);
};
}
Requests are created in action creators:
import {requestStart, requestError, requestSuccess} from 'LIB_NAME';
export function fetchUser(userId) {
return async (dispatch) => {
dispatch(requestStart([actionTypes.fetchUser, userId]));
const resp = await window.fetch(/* ... */);
if (resp.status !== 200) {
const err = await resp.json();
dispatch(requestError([actionTypes.fetchUser, userId], error));
return;
}
dispatch(requestSuccess([actionTypes.fetchUser, userId]));
const data = await resp.json();
dispatch({
type: actionTypes.fetchUser,
user: data,
});
};
}
If you attempt to start an already-started request, an error will be thrown.
To reset a request - for example, to ensure that when you exit and return to a page, you do not see state from a past request - simply use:
import {requestReset} from 'LIB_NAME';
dispatch(requestReset([actionTypes.getUser, userId]));
TODO: can an in-flight request be reset?
// components/UserDisplay.jsx
import React from 'react';
import {connect} from 'react-redux';
import {getRequest, REQUEST_ERROR, REQUEST_SUCCESS} from 'LIB_NAME';
import fetchUser from '../actions/fetchUser';
import * as actionTypes from '../actionTypes';
class DisplayUser extends React.Component {
componentWillMount() {
const {id, dispatch} = this.props;
dispatch(fetchUser(id));
}
render() {
const {request, user} = this.props;
if (request.status === REQUEST_SUCCESS) {
return (
<span>Username: {user}</span>;
);
} else if (request.status === REQUEST_ERROR) {
return (
<span>Error fetching user: {request.error}</span>
);
}
return <span>Loading user...</span>;
}
}
function mapStateToProps(state, props) {
const {id} = props;
return {
request: getRequest(state, [actionTypes.fetchUser, id]);
user: state.users[id],
};
}
export default connect(mapStateToProps)(UserDisplay);
// actions/fetchUser.js
import {requestStart, requestError, requestSuccess} from 'LIB_NAME';
export function fetchUser(userId) {
return async (dispatch) => {
dispatch(requestStart([actionTypes.fetchUser, userId]));
const resp = await window.fetch(/* ... */);
if (resp.status !== 200) {
const err = await resp.json();
dispatch(requestError([actionTypes.fetchUser, userId], error));
return;
}
dispatch(requestSuccess([actionTypes.fetchUser, userId]));
const data = await resp.json();
dispatch({
type: actionTypes.fetchUser,
user: data,
});
};
}
// reducers/users.js
export default function userReducer(state = {}, action) {
if (action.type === actionTypes.fetchUser) {
const {id} = action.user;
return {
[id]: action.user,
...state,
};
}
return state;
}
They potentially could, but a better way to handle custom state updating would be to simply dispatch another action alongside your request*
action.
You can create a counter in your action creator:
let todoRequestId = 0;
export function createTodo(text) {
return async (dispatch) => {
dispatch(requestStart([actionTypes.createTodo, todoRequestId]));
// ...
};
}
You'll likely want to then get a list of pending requests to be able to reference/display these requests (see below).
You can filter for requests from the async reducer:
const createTodoRequests = store.getState().requests[actionTypes.createTodo];
const pending = createTodoRequests.map((request) => request.status === REQUEST_PENDING);