Managing Async state with hooks and Redux

Rajesh Naroth
5 min readAug 6, 2019

--

As you write UI features, certain common patterns will start to emerge. For instance, You need to load a set of data asynchronously from an API (REST or GraphQL). This is an asynchronous process that mainly has three states: Loading data, Data Loaded, Error. The view may react to these states andshow a spinner, display a data table or popup error message.

React hooks makes async data load very easy to design. In this article we will look into hooking onto your state directly over the network using React hooks and loader functions. Anytime the loader changes, the hook’s useEffect function is fired. It is absurdly simple.

import { useEffect, useState } from "react";export const useAsyncLocalState = (loader) => {
const [isLoading, setIsLoading] = useState(false);
const [loadError, setLoadError] = useState(false);
const [payload, setPayload] = useState(null);
useEffect(() => {
const load = async () => {
try {
setIsLoading(true);
setLoadError(false);
const result = await loader(); setPayload(result);
setIsLoading(false);
} catch (e) {
setLoadError(true);
setIsLoading(false);
}
};
load();
}, [loader]);
return { isLoading, loadError, payload };
};

Now, the loader function. These functions are responsible for loading your data. You decide how you they do it. In this example, we will use axios.

export const getMarbles = (url) => async () => {
const apiData = await axios.get(url);
return Promise.resolve({ marbles: apiData.data });
};

We have to normalize our loaders to be no argument functions via currying. In our component, connect the loader to the hook.

export const Marbles = () => {
const [url] = React.useState(`/api/marbles`);
const loader = useCallback(getMarbles(url), [url]);
const { payload, isLoading, loadError} = useAsyncLocalState(loader);
return (
<>
{payload && payload.marbles && payload.marbles((item: any) => (
<p key={item.id}>{item.name}</p>
))}

{loadError && <p>Load Error</p>}
{isLoading && <p>Loading...</p>}
</>
);
};

Note how you have to use useCallBack() to feed it to the hook. In a functional component, at each refresh, a new function will be created, thus triggering the useEffect() hook that creates an endless loop. useCallback() will ensure that the function doesn’t change between refreshes. Unless the loader itself changes.

Sharing the state

So far, the loader state is only available locally within the component. What you loaded cannot be shared with other components. To do that, you must hook into a global state management option such as Context API or Redux. The idea is to put the loaded data into a global store along with its associated states. Redux provides a clean way of dealing with side effects. Action creators and Reducers are easily unit testable. Leaving all your side effects code in your actions. There is nothing wrong with a custom solution using the Context API but I prefer a actions up, data down approach when it comes to global state management. Also Redux is a well established pattern and it has tooling available.

With Redux, you do this:

  • dispatch an action to set a state flag such as isLoading to true.
  • fire off the async request in an action function that returns a promise.
  • when it returns, set isLoading to false, set the data by dispatching another “SET DATA” action
  • If there is an error , you would dispatch another action to set a loadError flag.

Before Hooks, your option was Higher Order Components to wrap that logic around a container component. This just creates unreadable code. Using hooks you can now hook on which ever state property you like in your component.

Designing useAsyncState()

Looking at useAsyncLocalState() hook, we know what we need to store globally via redux. Any data set or collection that is stored will have its own isLoaded/loadError/errorDetails state and the actions to modify them. Thus to make it generic enough, we namespace it using these action constant creators and action creators.

// Action constant creators
export const LOADING = (stateProperty) =>
`${stateProperty}/loading`;
export const SETDATA = (stateProperty)=>
`${stateProperty}/setdata`;
export const LOAD_ERROR = (stateProperty)=>
`${stateProperty}/loaderror`;
// Action creators
export const dataLoadingAction = (stateProperty) => ({
payload: {},
type: LOADING(stateProperty)
});
export const dataUpdateAction = (stateProperty, payload) => ({
payload,
type: SETDATA(stateProperty)
});
export const dataLoadingErrorAction = (stateProperty, error) => ({
payload: error,
type: LOAD_ERROR(stateProperty)
});

Now the reducer creator. This function returns a reducer for a specified state property.

export const getAsyncDataReducer = (stateProperty) => {
const reducer = (
state = {
isLoading: false,
loadError: false,
loadErrorDetails: null,
payload: null
},
action
) => {
switch (action.type) {
case LOADING(stateProperty):
return {
...state,
isLoading: true,
loadErrorDetails: null
};
case SETDATA(stateProperty):
return {
...state,
isLoading: false,
loadErrorDetails: null,
payload: action.payload
};
case LOAD_ERROR(stateProperty):
return {
...state,
isLoading: false,
loadError: true,
loadErrorDetails: action.payload
};
default:
return state;
}
};
return reducer;
};

All this can be abstracted away and forgotten. We just have to remember to create a new reducer, every time a new state property needs to be added. Like so:

const marbles = getAsyncDataReducer("marbles");
const items = getAsyncDataReducer("items");
const reducer = combineReducers({ marbles, items });
export const store = createStore(reducer);

Now we create a hook that will manage the data using reducers. It is quite simple. Note the usage of the new Redux hooks useDispatch() and useSelector():

export const useAsyncState = (stateProperty, loader) => {
const mounted = useRef(false);
const dispatch = useDispatch();
const stateValue = useSelector((state) => state[stateProperty]);
if (!stateValue) {
throw new Error(
`${stateProperty} not present in network state`
);
}
useEffect(() => {
const load = async () => {
try {
const result = await loader();
mounted.current &&
dispatch(dataUpdateAction(stateProperty, result.data));
return result;
} catch (e) {
mounted.current && dispatch(dataLoadingErrorAction(stateProperty, e));
}
};
mounted.current = true;
dispatch(dataLoadingAction(stateProperty));
load();
return () => {
mounted.current = false;
};
}, [loader, stateProperty]);
return stateValue;
};

Using the hook

It is almost exactly the same as the other.

export const Marbles = () => {
const [url] = React.useState(`/api/marbles`);
const loader = useCallback(getMarbles(url), [url]);
const { payload, isLoading, loadError} = useAsyncState("marbles", loader);
return (
<>
{payload && payload.marbles && payload.marbles((item: any) => (
<p key={item.id}>{item.name}</p>
))}

{loadError && <p>Load Error</p>}
{isLoading && <p>Loading...</p>}
</>
);
};

There are a few advantages to this hook.

  1. It reduces boilerplate code while managing the network states.
  2. Data once loaded is made available anywhere in the application.
  3. You don’t have to think in terms of Redux to manage your state. It is all nicely abstracted away.

Caveats

  1. For every piece of data, you must remember to initialize Redux with the corresponding async reducer.
  2. Data is loaded immediately when the hook is initialized. If you use the hook on the same data property more than once, the loaders will overwrite each other.
  3. If you check the codesandbox below, you’ll see that loader is optional. Thus you may want to consider providing a loader to at least one hook so that it can do the data fetching and the others can consume it.

So there you have it. One hook to rule them all. Just kidding. This is just an example. Hooks let you engineer UI solutions like these very elegantly.

If you want to see the code first, jump into: https://codesandbox.io/s/useasyncstate-0zsjw. It uses TypeScript and a little bit more polishing. But everything is there in spirit.

--

--

Rajesh Naroth
Rajesh Naroth

Written by Rajesh Naroth

Frontend Architect, San Jose, CA

Responses (2)