DEV Community

Sharad Chand
Sharad Chand

Posted on • Edited on • Originally published at sharadchand.com

Async Redux Actions using Hooks

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;
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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,
  };
}
Enter fullscreen mode Exit fullscreen mode

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>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (2)

Collapse
 
ksi9302 profile image
Peter Sugin Kim

Thanks for the amazing tutorial, I was just about to hate myself for making too many async actions.

Collapse
 
mannyanebi profile image
Emmanuel Anebi

Lovely. Thanks for writing this amazing tutorial