4 Techniques For Loading States In Redux
Loading indicators are one of the most common methods for providing feedback when a user action results in an asynchronous request. In order to show a loading indicator you need to know if your component is currently loading data. This turns out not to be a trivial exercise when you start building a complex application with Redux.
In this post I share four techniques for representing loading states in Redux applications. These techniques are extracted from our React/Redux applications at Clara Labs.
Implicit Loading State
In rare cases, in which you only load the data for a reducer once you can rely on the absence of a value to indicate that the data is loading. The drawback of this approach is highlighted when the request responsible for loading this data encounters an error; this results in an absence of data but it is not because the data is still being loaded but rather because the data could not be loaded.
// reducer
function userAuth(state = {}, action) {
case 'RECEIVE_AUTH':
return { isAuthenticated: action.payload };
case 'FETCH_AUTH':
case 'ERROR_AUTH':
default:
return state;
}
// usage
const loading = !('isAuthenticated' in state.userAuth);
Simple Boolean Value
Using a simple boolean value (e.g. loading
) on a reducer works well for reducers which meet two criteria:
- Acted on by at most one in-flight async request
- Reduces data that is updated atomically
This technique resolves some of the drawbacks of the first method such as allowing you to tease out situations where the request completed but resulted in an error that prevented data from being set.
// reducer
function userAuth(state = { loading: false }, action) {
case 'FETCH_AUTH':
return {
...state,
loading: true,
};
case 'RECEIVE_AUTH':
return {
isAuthenticated: action.payload,
loading: false,
};
case 'ERROR_AUTH':
return {
...state,
loading: false,
};
default:
return state;
}
// usage
const loading = state.userAuth.loading;
Loading Keys List
Another common scenario you will run into is a reducer that operates over an object composed of many keys that can be updated individually. For example, if you have a settings component that allows the user to update the keys of the settings object individually. The previous techniques won't give you enough information to deliver a great user experience and provide per-key loading indicators.
In this case you can use a simple list that contains all the keys that are currently being fetched. Determining if you need to show a loading indicator for a given key is as easy as doing a contains
on this list.
// reducer
function userPrefs(state = { inflightKeys: [] }, action) {
const { inflightKeys } = state;
const { payload: { key, value } } = action;
case 'FETCH_KEY'
return {
...state,
inflightKeys: inflightKeys.concat(key),
};
case 'RECEIVE_KEY':
return {
...state,
[key]: value,
inflightKeys: inflightKeys.filter(attr => attr !== key),
};
case 'RECEIVE_KEY':
return {
...state,
inflightKeys: inflightKeys.filter(attr => attr !== key),
};
default:
return state;
}
// usage
const loading = state.userPrefs.inflightKeys.contains('emailAddress');
Using A LoadingGroup
On rare occassions you encounter a situation in which none of these earlier solutions suffice. For example, if you have a reducer that may have multiple inflight async requests or whose data is not guaranteed to be updated atomically.
Since we follow the Redux Ducks we end up with a few key reducers that are used by many components in our application and as a result may have multiple async requests inflight at any time. We have a couple of reducers that require more than one async request to fully load the data and we want to only show a single loading indicator that waits on all the data in that reducer to load instead of showing multiple or staged loading indicators.
To solve this problem we built a quick little class that simplifies bookkeeping of these kinds of situations.
function LoadingGroup(fetchesInProgress = 0) {
this.fetchesInProgress = fetchesInProgress;
this.isLoading = fetchesInProgress > 0;
}
LoadingGroup.prototype.startFetch = function() {
return LoadingGroup(this.fetchesInProgress + 1);
};
LoadingGroup.prototype.completeFetch = function() {
if (this.fetchesInProgress === 0) {
throw new Error('Could not complete fetch, none were in progress');
}
return LoadingGroup(this.fetchesInProgress - 1);
};
We can start as many fetches as possible and as each one completes we update the fetchesInProgress
count and recheck whether we are still awaiting any fetches to complete successfully. Using this in our reducer and components is very straightforward and easy to understand.
// reducer
function dashboard(state = { loadingGroup: LoadingGroup() }, action) {
const { payload: { key, value } } = action;
case 'FETCH_PICS':
case 'FETCH_NOTES':
return {
...state,
loadingGroup: loadingGroup.startFetch(),
};
case 'RECEIVE_PICS':
return {
...state,
pics: action.payload,
loadingGroup: loadingGroup.completeFetch(),
};
case 'RECEIVE_NOTES':
return {
...state,
notes: action.payload,
loadingGroup: loadingGroup.completeFetch(),
};
case 'ERROR_PICS':
case 'ERROR_NOTES':
return {
...state,
loadingGroup: loadingGroup.completeFetch(),
};
default:
return state;
}
// usage
const loading = state.dashboard.loadingGroup.isLoading;
Using a combination of these four techniques in your Redux application will allow you to deliver a great user experience without breaking the principles of Redux applications or having to reorganize the structure of your data just to meet UI requirements.
Built With: Redux 3.6.0, React 0.14.8