How to Cache API Requests With Redux
A tutorial on how to setup API requests caching in the Redux store.
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:
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:
redux-thunk
— async actions in reduxredux-api-middleware
— middleware for calling APIsredux-cached-api-middleware
— a light-weight library (uses above libs as peer dependencies) that adds caching capabilitiesredux-devtools-extension
— hook your redux state with browser dev tools
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';67export 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';67import App from './App';8import { store } from './state';9import './index.css';1011ReactDOM.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';56import Spinner from './Spinner';7import Articles from './Articles';89const CACHE_KEY = 'GET/hacker-news/top';1011class App extends React.Component {12 componentDidMount() {13 const { requestData } = this.props;14 requestData();15 }1617 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}3233const 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.cache50 .get(api.constants.CACHE_TYPES.TTL_SUCCESS)51 .buildStrategy({ ttl: 600000 }), // 10 minutes52 },53 }),54 );55 },56 }),57);5859App.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};7071export 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 (3 <WithUserInfo4 render={({ username }) => <header>Hello, {username}!</header>}5 />6 );7}
1function CTAButton() {2 return (3 <WithUserInfo4 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';89const persistConfig = {10 key: 'hn-lite-1',11 storage,12};1314const 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};2122export const store = createStore(23 persistCombineReducers(persistConfig, {24 [api.constants.NAME]: api.reducer,25 }),26 composeWithDevTools(applyMiddleware(thunk, apiMiddleware, apiNormalizer)),27);2829export 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';78import App from './App';9import { store, persistor } from './state';10import './index.css';1112ReactDOM.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:
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:
redux-api-middleware
let’s you pass your own fetch implementation- with
redux-cached-api-middleware
you can either pass a predefined caching strategy or define you ownshouldFetch
resolver
Happy caching!
Software Engineer
I build stuff using JavaScript and share my findings from time to time. I hope you will find something useful here.