If you are building a complex React application, you are likely using a back end service and an API. If you are managing state in React, you likely are using redux. Both are great choices that I would highly recommend for your React application's architecture. However, redux's out-of-the-box synchronous state manipulation is not particularly compatible with asynchronous server responses, leaving many developers scratching their heads. There are a lot of states involved in an asynchronous call, but isn't an API call just a single action?
I would like to walk you through the standardized states involved in an asynchronous API call and their relationship to the redux store.
By the end of this article, you should understand how to write an asynchronous redux action that handles each state of an API call. Each API call has the same states and logic behind when those states are triggered, so in order to prevent copy-pasting the same boilerplate for each asynchronous redux action, I will also offer an open-source package that I've used almost religiously that will handle the action creation for you.
Prerequisites 📍
To enable asynchronous actions on your redux store, you will want to apply the redux-thunk middleware.
For API calls, I will be using the standardized fetch
API. If your target browser does not support the fetch
API, I would recommend a fetch
polyfill. I also recommend using an AbortController
polyfill if you want to be able to abort your API calls, but not if you do not desire this feature. If you prefer an alternative to the fetch
API, such as axios
or XMLHttpRequests
, they are absolutely capable of handling asynchronous Redux state management, but my code examples will be based on the fetch
API.
What is an asynchronous action? 🐌
The first step is understanding what you are creating - unlike previous action creators that returned an action object that was immediately sent to the reducers, an asynchronous action is not an object but a function that is immediately invoked. That function accepts two parameters, each of which is a function. The first is the dispatch
function, used to dispatch an action; the second is a getState
function, used to get the current Redux state.
// Synchronously add an employee.
// addEmployee("Bob");
const addEmployee = (name) => ({
type: 'ADD_EMPLOYEE',
name
});
// Asynchronously add an employee.
// addEmployeeAsync("Bob")
const addEmployeeAsync = (name) => {
// Since the return value of this action creator
// accepts dispatch as a parameter instead of
// returning what is to be dispatched,
// I may dispatch at my leisure and as many times as I want.
return (dispatch, getState) => {
// I want to immediately and synchronously add the employee.
dispatch(addEmployee(name));
// I want to asynchronously remove the employee.
// This is a second action in a single action creator.
setTimeout(
() => {
dispatch(removeEmployee(name));
},
0
);
// I want to asynchronously re-add that employee after 5 seconds.
// This is a third action in a single action creator.
setTimeout(
() => {
dispatch(addEmployee(name));
},
5000
);
};
};
Normally, when your action creator returns an object, that object gets passed to your reducer. Now, when your action creators return functions, the redux-thunk middleware will immediately invoke that function instead of passing it to the reducer. That function can do anything. Unlike other action creators, this function does not return the action object. Using the dispatch parameter, you can dispatch action objects to the reducer. The upside of manually dispatching them instead of returning them is that you can dispatch as many actions as needed, such as one for each state in an API call, despite having only dispatched one action creator.
In summation, your components dispatch one asynchronous action (in this case, addEmployeeAsync
). That asynchronous action in turn dispatches multiple actions (addEmployee
, removeEmployee
, then addEmployee
again). There is no reason to add, remove, then add again. It’s just an example of your freedom in design.
The States of the Fetch API 🎌
Now that we know how to create an action that can dispatch multiple states over time, let’s identify and dispatch the states of a fetch request.
The first state of an API request is requested (loading). The request has been dispatched, but we have not yet received a response.
The subsequent state of an API request is either received (success) or rejected (error) depending on the response from the server.
The final, potential state of an API request is aborted (cancelled) for if you or the user terminates the request before receiving a response.
For each API endpoint required to power your application, an initial Redux state may look something like this:
{
"myApiData": {
"abortController": null,
"aborted": false,
"error": null,
"loading": false,
"response": null
}
}
You will want an action for each of these states, since each of the API request’s states should be reflected in your application.
// When the API is requested,
// this action is sent to the reducer.
// The abortController tied to the request,
// so passed to the request action creator.
// You may store it in your redux state for future use.
const requestMyApi = abortController => ({
type: 'REQUEST_MY_API',
abortController
});
// When the API responds,
// this action is sent to the reducer.
// It includes the response, which is probably
// the entire point of this process.
const receiveMyApi = response => ({
type: 'RECEIVE_MY_API',
response
});
// When the API fails to respond,
// this action is sent to the reducer.
// The provided error is included, which can
// be used to display to users or debug.
const rejectMyApi = err => ({
type: 'REJECT_MY_API',
error: err
});
// When the API request has been aborted or cancelled,
// this action is sent to the reducer.
const abortMyApi = () => ({
type: 'ABORT_MY_API'
});
The Abort Action 🙅
In order for the API request to notify the developer that it has been cancelled, it must be passed an AbortSignal
at instantiation. Despite this not being the first action dispatched, it will be the first we write, because it must be written before the API request is initialized.
let abortController = null;
let signal;
// Since AbortController is not well-supported yet, we check for its existence.
if (typeof AbortController !== 'undefined') {
abortController = new AbortController();
signal = abortController.signal;
signal.addEventListener('abort', () => {
dispatch(abortMyApi());
});
}
If the browser supports it, we create an AbortController
, and we add a listener for the abort signal. When the abort signal event occurs, we dispatch the abort action. The AbortController
will later be passed as a part of the request action. This allows you to store it in your redux state, giving your components and users access to manually abort an API request via the controller.
When a ABORT_MY_API
action is received by your reducer, you can manipulate the state accordingly: It is no longer loading, there was no response, there was no error, and it was aborted. You may prefer replacing the aborted flag with an error string to simplify your logic, if that matches your use case. I would suggest against it, however, due to such logic differences as “Can the user re-request the payload if they aborted the previous one? If an error occurred during the previous one?”
The Request Action 📞
You should use the request action to enable a loading view. Consider using a loading animation or text to notify your user that something is happening. The feedback goes a long way in making your application feel responsive. The REQUEST_MY_API
action will toggle the state.myApi.loading
from false to true. Your components can now respond to this redux state accordingly. Components that depend on the response from my API can display that they are in the process of loading.
Since a request is instantiated immediately, you can dispatch that action immediately in your asynchronous action creator: dispatch(requestMyApi(abortController))
.
Since we have told the reducer that we have requested the data, we should actually request it: fetch(URL, { signal })
. You can adjust your fetch options as needed. The signal
is the one created as a part of the abort handler above.
It takes more than just requesting the data, we also need to handle the response.
The Response Action 🙌
Once the fetch Promise resolves, we can take that response, parse it accordingly (as text or JSON), and send the parsed data to the reducer, making it accessible to your components.
fetch(URL, { signal })
.then(response => {
// If this payload is JSON, use this:
return response.json();
// If this payload is not JSON, use this:
return response.text();
})
.then(data => {
// Now that we've parsed the response,
// we can send it to the reducer.
dispatch(receiveMyApi(data));
});
The Error Action ❌
The error action is even easier. Since we’re working with promises, we just catch
!
fetch(URL, { signal })
.then(parseData)
.then(receiveMyApi)
.then(dispatch)
.catch(err => {
// An error occurred at some point in this Promise.
// Pass the error to the reducer.
dispatch(rejectMyApi(err));
});
Considerations 🤔
There is more complex error-handling involved if your API is successfully responding with error status codes and an error message as a part of the parsed payload. I won’t cover that case in detail here, because it does not apply to all APIs, but you can see how I handled it in the source code of this package.
You also have the power of the getState
function. You may use the current redux state to modify (or even ignore) your current fetch request. Depending on the scenario and action, sometimes I will get the current state to see if the request is already loading or has responded in the past. If it has, I just don’t fetch. The async action was clearly dispatched in error, so I can safely ignore it — I already have the data, so fetching it will provide me with no benefit.
Can’t Most of This Be Automated? 🤖
Yes! The fetch-action-creator
package does all of the above so that you don’t have to copy-paste this boilerplate for every API action. Every API call will do the same series of things: create an abort controller and signal, fetch the request, parse the response, check the response for error status codes, and dispatch an action for each of the four states involved in the process.
If I love anything, it’s DRY code! That’s why I use and recommend a function that will do all of these things for you. All you are left to do is provide the differences between any two given API calls: a unique identifier, the URL, and the fetch options.
Just npm install fetch-action-creator
or yarn add fetch-action-creator
!
fetch-action-creator 🐶🎾
Be sure to understand the difference between an action and an action creator. The fetch-action-creator
package does not return an action creator. It is an action creator, so it returns an asynchronous action, meaning it returns the (dispatch, getState) => {}
function.
Your action creator will look something like this:
import fetchActionCreator from 'fetch-action-creator';
export const fetchMyApi = () =>
fetchActionCreator(
'MY_API',
'https://path.to/api',
null // fetch options, if any
);
The 'MY_API'
string is used to generate the Redux action types: 'REQUEST_MY_API'
, 'RESOLVE_MY_API'
, 'REJECT_MY_API'
, and 'ABORT_MY_API'
.
Your React components will only bind and call the fetchMyApi
action creator, which notably has no parameters in this example.
You can use parameters to customize your fetch actions. It’s a little more work to extend your actions inline, but the payoff in extensibility is huge.
import fetchActionCreator from 'fetch-action-creator';
// We now call fetchAddEmployee("Bob")
const fetchAddEmployee = name =>
fetchActionCreator(
'ADD_EMPLOYEE',
'https://path.to/employees',
// POST Bob
{
body: name,
method: 'POST'
},
// For each action, merge with object { name }
// to add a name property containing
// employee's name to the action object.
{
onAbort: { name },
onReject: { name },
onRequest: { name },
onResolve: { name }
}
);
You can check out the documentation for advanced options on mutating the Redux actions.
The fetch-action-creator
package is open-source on GitHub. Pull requests are welcome!
Conclusion 🔚
If you liked this article, feel free to give it a heart or unicorn. It’s quick, it’s easy, and it’s free! If you have any questions or relevant commentary, please leave them in the comments below.
To read more of my columns, you may follow me on LinkedIn, Medium, and Twitter, or check out my portfolio on CharlesStover.com.
Top comments (0)