Async Redux Actions using Hooks
Sharad Chand
Posted on July 3, 2020
One of the pain points in using Redux has always been writing the same thing over and over again. You create a store with the default values in it, then go on to write a switch
case
reducer and write an action when an operation is to be done. It gets even more complex when the action is async. I'll not go over it as you most likely have gone through that drill. Of late, Redux has introduced Redux Toolkit to fix these pain points.
The Problem
Before I go into using hooks based actions with Redux, I'll lay out the code structure which I have encountered in all my apps which has some form of async action. For an async action, there are three states of operation: loading, data, error. When the action has not yet been initiated, all the values are falsy (false
or null
). Once the action is invoked, loading becomes true
and on completion loading returns to being false
and either data has some value after a successful operation or error has a value if unsuccessful.
This can be laid out in Redux in the following way:
import { createSlice } from '@reduxjs/toolkit';
const store = createSlice({
name: 'store',
initialState: {
loading: false,
data: null,
error: null,
},
reducers: {
setLoading: (state) => {
state.loading = true;
state.data = null;
state.error = null;
},
setSuccess: (state, action) => {
state.loading = false;
state.data = action.payload;
state.error = null;
},
setFailure: (state, action) => {
state.loading = false;
state.data = null;
state.error = action.payload;
},
},
});
This is pretty straight-forward and easy to just scaffold to use for smaller programs. But this becomes a problem if there are a lot of slices of data and each one needs to have their own async states be stored. To make a naive solution, we may use a global loading and error states but this becomes a problem when there are two or more async actions which overlap and muddle with this shared state. Also, there is the problem of having to dispatch actions to move the async action to move from one state to another:
// Before async operation.
dispatch(store.actions.setLoading());
try {
// The async operation is run and it returns data on success.
dispatch(store.actions.setSuccess(data));
} catch (error) {
// If an error occurs.
dispatch(store.actions.setFailure(error));
}
You might already be using redux-thunk to do this or other similar async solution just for API requests.
But why must we do this ritual over and over again for each async action? Here comes hooks to the rescue.
The Solution
Though hooks are the answer to it, I like to use my secret (or not so secret) sauce of react-use's useAsyncFn
. This function wraps the async action and generates async states for us. This state is totally unique to this hook and need not be stored in the Redux store. We can selectively store only the required bits from the async action to the Redux store.
// This is the store which stores just the part that we are actually interested in.
const store = createSlice({
name: 'data',
initialState: null,
reducers: {
setState: (state, action) => {
state = action.payload;
},
},
});
// This is an hook action which constructs and wraps an async action to produces `state` for us.
function useAsyncRequest() {
const dispatch = useDispatch();
// `state` maintains the async `loading` and `error` state of the action.
// `request` is the wrapper of the action which we can invoke in our app.
const [state, request] = useAsyncFn(async () => {
const response = await axios.get('/api/data');
dispatch(store.actions.setState(response.data));
}, [dispatch]);
return {
state,
request,
};
}
The state
in the above example has the fields state.loading
, state.error
. And as for the data part, that is directly dispatched and stored within the Redux store. Internally the useAsyncFn
wraps the action with a try catch
block and stores the loading and error state in its internal variables.
In one of the components, you may use this action like what I have done below:
function Button() {
const data = useSelector((state) => state.data);
const { state, request } = useAsyncRequest();
return (
<>
// Show the data from the backend.
{JSON.stringify(data)}
<button onClick={request}>{state.loading ? 'Loading' : 'Click'}</button>
</>
);
}
Just subscribe to the state changes of the redux and request state; and dispatch an action with the wrapper function request
.
The Benefits
- You do not have to write
loading
,error
boilerplate code ever again. - No
try catch
littered in your API requests. They are just wired directly to the async state's error field. - Redux store is neat and focused. It contains only the real data that is to be shown to the user.
- The data always comes from the store and the async state from the hooks which to me is a clear separation of concerns. Async state is bound to the actual request where as data to the app.
Posted on July 3, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
February 11, 2021