2018-05-14

How to Cache API Requests With Redux

A tutorial on how to setup API requests caching in the Redux store.

banner1Photo by Denny Müller

After implementing API request caching a couple of times in recent years, I decided to give back to the open-source community. So I published a library called redux-cached-api-middleware and thought I’d share my thoughts on why you’d want to cache API requests and how you can do it in your apps with fairly low effort.

Why cache API requests

Well caching helps to re-load your apps faster and I believe we all hate those loading spinners 😡… Faster re-load times lead to better UX, and higher conversion rates. So if you want higher revenue, you probably should consider this technique. You probably already cache you web app’s static assets via caching headers, but you can go further than this and preserve the whole application state. That’s where redux-cached-api-middleware in combination with other well know libraries can fast track your project 🏎!

How to start caching

For learning purposes let’s build a minimalistic Hacker News app that displays top articles. The example will be a React app, but core ideology applies to any Redux project.

The create-react-app is an easy way to start:

1yarn create react-app hn-lite && cd hn-lite

Which should create a project with a structure, similar to this:

Project structure

The setup

Install some core dependencies:

1yarn add babel-polyfill \
2 whatwg-fetch \
3 redux \
4 react-redux \
5 redux-thunk \
6 redux-api-middleware \
7 redux-cached-api-middleware \
8 redux-devtools-extension \
9 babel-polyfill

We added some polyfills and some Redux related dependencies, let’s identify what are they used for:

Setup the Redux store instance in state.js file:

1import { createStore, combineReducers, applyMiddleware } from 'redux';
2import thunk from 'redux-thunk';
3import { apiMiddleware } from 'redux-api-middleware';
4import api from 'redux-cached-api-middleware';
5import { composeWithDevTools } from 'redux-devtools-extension';
6
7export const store = createStore(
8 combineReducers({
9 [api.constants.NAME]: api.reducer,
10 }),
11 composeWithDevTools(applyMiddleware(thunk, apiMiddleware))
12);

And wrap our App component with Redux Provider:

1import React from 'react';
2import ReactDOM from 'react-dom';
3import { Provider } from 'react-redux';
4import 'babel-polyfill';
5import 'whatwg-fetch';
6
7import App from './App';
8import { store } from './state';
9import './index.css';
10
11ReactDOM.render(
12 <Provider store={store}>
13 <App />
14 </Provider>,
15 document.getElementById('root')
16);

That’s it, we’re ready to start caching 🚀

Let’s call an API

We’ll be using News API to fetch top headlines from Hacker News. 10 minutes is probably a reasonable cache time, so we will use a caching strategy to define the logic in the App.js file.

1import React from 'react';
2import PropTypes from 'prop-types';
3import { connect } from 'react-redux';
4import api from 'redux-cached-api-middleware';
5
6import Spinner from './Spinner';
7import Articles from './Articles';
8
9const CACHE_KEY = 'GET/hacker-news/top';
10
11class App extends React.Component {
12 componentDidMount() {
13 const { requestData } = this.props;
14 requestData();
15 }
16
17 render() {
18 const { result } = this.props;
19 if (result && result.fetched) {
20 const { fetching, error, successPayload } = result;
21 return (
22 <div className="content">
23 {fetching && <Spinner small />}
24 {!fetching && error && <div className="error">An error occurred</div>}
25 {successPayload && <Articles items={successPayload.articles} />}
26 </div>
27 );
28 }
29 return <Spinner />;
30 }
31}
32
33const enhance = connect(
34 (state) => ({
35 result: api.selectors.getResult(state, CACHE_KEY),
36 }),
37 (dispatch) => ({
38 requestData() {
39 return dispatch(
40 api.actions.invoke({
41 method: 'GET',
42 headers: {
43 Accept: 'application/json; charset=utf-8',
44 'x-api-Key': process.env.REACT_APP_API_KEY,
45 },
46 endpoint: 'https://newsapi.org/v2/top-headlines?sources=hacker-news',
47 cache: {
48 key: CACHE_KEY,
49 strategy: api.cache
50 .get(api.constants.CACHE_TYPES.TTL_SUCCESS)
51 .buildStrategy({ ttl: 600000 }), // 10 minutes
52 },
53 })
54 );
55 },
56 })
57);
58
59App.propTypes = {
60 result: PropTypes.shape({
61 fetching: PropTypes.bool.isRequired,
62 fetched: PropTypes.bool.isRequired,
63 error: PropTypes.bool.isRequired,
64 timestamp: PropTypes.number,
65 successPayload: PropTypes.any,
66 errorPayload: PropTypes.any,
67 }),
68 requestData: PropTypes.func.isRequired,
69};
70
71export default enhance(App);

This way if the App component would be re-mounted in the 10-minute interval, the API response would be returned from the cache and not requested again.

This may not look that useful for one component, but when you have multiple components that need the data from the same endpoint — it can get really handy. For example at my workplace we have a WithUserInfo component that fetches some basic user data and passes to another component (see render prop). We have multiple WithUserInfo elements in the React tree, but the API still gets called only once (even when the cache gets outdated).

1function Header() {
2 return <WithUserInfo render={({ username }) => <header>Hello, {username}!</header>} />;
3}
1function CTAButton() {
2 return (
3 <WithUserInfo
4 render={({ type }) => {
5 switch (type) {
6 case 'FREE':
7 return <Button primary>Check out premium features</Button>;
8 case 'STANDART':
9 return <Button secondary>Feedback</Button>;
10 default:
11 return <Button primary>Try the product for free</Button>;
12 }
13 }}
14 />
15 );
16}

I really like this approach, because then you don’t have to load all the data in some AppWrapper component . Every component not only encapsulates the representation part but also the API data source logic. This way you can achieve a progressive loading effect — data is requested only when a component that needs it is added to the React tree.

Recovering after page refresh

Another cool trick you can do with caching is persist your redux state to offline storage (for e.g. localStorage) via redux-persist.

Let’s update our setup, so that even when user re-opens the tab the previous state will be in place:

1import { createStore, applyMiddleware } from 'redux';
2import thunk from 'redux-thunk';
3import { apiMiddleware } from 'redux-api-middleware';
4import api from 'redux-cached-api-middleware';
5import { composeWithDevTools } from 'redux-devtools-extension';
6import { persistStore, persistCombineReducers } from 'redux-persist';
7import storage from 'redux-persist/lib/storage';
8
9const persistConfig = {
10 key: 'hn-lite-1',
11 storage,
12};
13
14const apiNormalizer = (store) => (next) => (action) => {
15 const result = next(action);
16 if (action.type === 'persist/REHYDRATE') {
17 store.dispatch(api.actions.invalidateCache());
18 }
19 return result;
20};
21
22export const store = createStore(
23 persistCombineReducers(persistConfig, {
24 [api.constants.NAME]: api.reducer,
25 }),
26 composeWithDevTools(applyMiddleware(thunk, apiMiddleware, apiNormalizer))
27);
28
29export const persistor = persistStore(store);

Notice the apiNormalizer middleware — it is used to recover from a broken state (when the user closes the tab, but the request was in progress).

And wrap our App with PersistGate component:

1import React from 'react';
2import ReactDOM from 'react-dom';
3import { Provider } from 'react-redux';
4import { PersistGate } from 'redux-persist/integration/react';
5import 'babel-polyfill';
6import 'whatwg-fetch';
7
8import App from './App';
9import { store, persistor } from './state';
10import './index.css';
11
12ReactDOM.render(
13 <Provider store={store}>
14 <PersistGate persistor={persistor}>
15 <App />
16 </PersistGate>
17 </Provider>,
18 document.getElementById('root')
19);

Refresh the page and notice the state getting restored:

redux dev tools

Check the full source code.

Conclusion

Caching is great UX overall and progressive loading perceives that the application is loading faster. Everyone uses APIs differently, so the API wrappers have to be flexible:

Happy caching!


profile
Karolis Šarapnickis 🇱🇹

Front-end Tech Lead @ Tesonet

I build stuff using JavaScript and share my findings from time to time. I hope you will find something useful here.

GithubTwitterLinkedInemail