SaltyCrane Blog — Notes on JavaScript and web development

Caching a filtered list of results w/ Redux, React Router, and redux-promise-memo

This post shows how to cache API data for a React + Redux application using ideas from my library, redux-promise-memo. The example app displays a filtered list of vehicles, a sidebar with make and model filters, and a detail page for each vehicle. Caching is used to prevent re-fetching API data when navigating between the detail pages and the list page.

In addition to React and Redux, this example uses React Router and redux-promise-middleware, though alternatives like redux-pack or Gluestick's promise middleware can be used also.

The example is broken into 3 sections: 1. Basic features (no caching), 2. Caching (manual setup), and 3. Caching with redux-promise-memo.

Basic features
  • filtering vehicles by make and model is done by the backend vehicles API
  • when a make is selected, the models API populates the models filter for the selected make
  • filter parameters (make and model) are stored in the URL query string to support deep linking to a page of filtered results and to support browser "back" and "forward" navigation
  • each vehicle detail page also has a unique route using the vehicle id in the route
Highlighted feature - caching
  • API responses are not re-fetched when moving back and forward between pages

Code for basic features (no caching)

The full example code is here on github. A demo is deployed here.

VehicleFilters.js
class VehiclesFilters extends React.Component {
  componentDidMount() {
    this._fetchData();
  }

  componentDidUpdate(prevProps) {
    if (this.props.query !== prevProps.query) {
      this._fetchData();
    }
  }

  _fetchData() {
    let { dispatch, query } = this.props;
    dispatch(actions.fetchModels(query.make));
  }

  render() {
    let { changeQuery, models, query } = this.props;
    return (
      <Container>
        <Select
          label="Make"
          onChange={e =>
            changeQuery({ make: e.currentTarget.value, model: "" })
          }
          options={["All makes", "Acura", "BMW", "Cadillac", "..."]}
          value={query.make || ""}
        />
        {query.make && (
          <Select
            label="Model"
            onChange={e => changeQuery({ model: e.currentTarget.value })}
            options={["All models", ...models]}
            value={query.model || ""}
          />
        )}
      </Container>
    );
  }
}

export default compose(
  withVehiclesRouter,
  connect(state => ({ models: state.models }))
)(VehiclesFilters);

The VehicleFilters component has <select> inputs for the "Make" and "Model" filters.

  1. when a user changes the make to "BMW", the changeQuery function adds the ?make=BMW query string to the URL
  2. when the query string is updated, componentDidUpdate calls fetchModels which calls the models API
  3. when the API responds, the models list is stored in Redux at state.models
  4. when the Redux state changes, the "Model" <select> is updated with the new list of models

Notes:

  • withVehiclesRouter is a higher-order component that adds the following props to VehicleFilters:

    • query - the parsed query string
    • changeQuery - a function used to update the query string

    See the implementation here

  • the route is the single source of truth for the make and model parameters. They are stored only in the route and not in Redux.

VehiclesList.js
class VehiclesList extends React.Component {
  componentDidMount() {
    this._fetchData();
  }

  componentDidUpdate(prevProps) {
    if (this.props.query !== prevProps.query) {
      this._fetchData();
    }
  }

  _fetchData() {
    let { dispatch, query } = this.props;
    dispatch(actions.fetchVehicles(query));
  }

  render() {
    let { isLoading, vehicles } = this.props;
    return (
      <Container>
        {isLoading ? (
          <Spinner />
        ) : (
          vehicles.map(vehicle => (
            <Link key={vehicle.id} to={`/vehicles/${vehicle.id}`}>
              <VehicleCard {...vehicle} />
            </Link>
          ))
        )}
      </Container>
    );
  }
}

export default compose(
  withVehiclesRouter,
  connect(state => ({
    isLoading: state.isLoading,
    vehicles: state.vehicles
  }))
)(VehiclesList);

The VehiclesList component gets data the same way the "Model" <select> input does.

  1. the previous VehicleFilters component updates the route query string with a make or model
  2. when the route query string is updated, this component's componentDidUpdate calls fetchVehicles which calls the vehicles API
  3. when the API responds, the vehicle list is stored in Redux at state.vehicles
  4. when the Redux state changes, this component is updated with the new list of vehicles.

Each vehicle card is wrapped with a react-router <Link>. Clicking the vehicle card navigates to a new route, /vehicles/{vehicleId}.

VehicleDetail.js
class VehicleDetail extends React.Component {
  componentDidMount() {
    let { dispatch, vehicleId } = this.props;
    dispatch(actions.fetchVehicle(vehicleId));
  }

  render() {
    let { isLoading, vehicle } = this.props;
    return isLoading ? <Spinner /> : <VehicleCard {...vehicle} />;
  }
}

export default compose(
  withVehiclesRouter,
  connect(state => ({
    isLoading: state.isLoading,
    vehicle: state.vehicle
  }))
)(VehicleDetail);

VehicleDetail gets data in the same way as the "Model" filter and VehiclesList. One difference is that it doesn't need to use componentDidUpdate because the API input parameter (vehicleId) never changes.

  1. to display a vehicle detail page, a vehicle <Link> is clicked in the previous VehiclesList component which changes the route to /vehicles/{vehicleId}
  2. when the route changes, this component is rendered and passed the vehicleId prop.
  3. when this component is rendered, componentDidMount calls fetchVehicle which calls the vehicle detail API

The withVehiclesRouter higher-order component takes match.params.vehicleId from react-router and passes it to VehicleDetail as vehicleId.

App.js
let store = createStore(reducer, applyMiddleware(promiseMiddleware()));

let VehiclesPage = () => (
  <React.Fragment>
    <VehiclesFilters />
    <VehiclesList />
  </React.Fragment>
);

let App = () => (
  <Provider store={store}>
    <BrowserRouter>
      <Switch>
        <Route component={VehicleDetail} path="/vehicles/:vehicleId" />
        <Route component={VehiclesPage} path="/vehicles" />
      </Switch>
    </BrowserRouter>
  </Provider>
);

This shows react-router route configuration for the app and also the addition of redux-promise-middleware.

actions.js
export let fetchModels = make => ({
  type: "FETCH_MODELS",
  payload: fakeModelsApi(make)
});

export let fetchVehicle = vehicleId => ({
  type: "FETCH_VEHICLE",
  payload: fakeVehicleApi(vehicleId)
});

export let fetchVehicles = params => ({
  type: "FETCH_VEHICLES",
  payload: fakeVehiclesApi(params)
});
reducers.js
let isLoading = (state = false, action) => {
  switch (action.type) {
    case "FETCH_VEHICLE_PENDING":
    case "FETCH_VEHICLES_PENDING":
      return true;
    case "FETCH_VEHICLE_FULFILLED":
    case "FETCH_VEHICLES_FULFILLED":
      return false;
    default:
      return state;
  }
};

let models = (state = [], action) => {
  switch (action.type) {
    case "FETCH_MODELS_FULFILLED":
      return action.payload;
    default:
      return state;
  }
};

let vehicle = (state = [], action) => {
  switch (action.type) {
    case "FETCH_VEHICLE_FULFILLED":
      return action.payload;
    default:
      return state;
  }
};

let vehicles = (state = [], action) => {
  switch (action.type) {
    case "FETCH_VEHICLES_FULFILLED":
      return action.payload;
    default:
      return state;
  }
};

export default combineReducers({
  isLoading,
  models,
  vehicle,
  vehicles
});

These are the actions and reducers that are using redux-promise-middleware.

  • fakeModelsApi, fakeVehicleApi, and fakeVehiclesApi are meant to mimick a HTTP client like fetch or axios. They return a promise that resolves with some canned data after a 1 second delay.
  • the models, vehicle, and vehicles reducers store the API responses in the Redux state

Caching (manual setup)

In the above setup, API calls are made unecessarily when navigating back and forth between vehicle details pages and the main vehicles list. The goal is to eliminate the uncessary calls.

The vehicle data is already "cached" in Redux. But the app needs to be smarter about when to fetch new data. In some apps, a component can check if API data exisits in Redux and skip the API call if it is present. In this case, however, doing this would not allow updating the results when the make or model filters change.

The approach I took in redux-promise-memo was to fetch only when API input parameters changed:

  • the API input parameters (e.g. make, model) are stored in Redux
  • when deciding whether to make a new API call, the current API input paramters are tested to see if they match the parameters previously stored in Redux
  • if they match, the API call is skipped, and the data already stored in Redux is used

Below are changes that can be made to implement this idea without using the library. The solution with the redux-promise-memo library is shown at the end.

The full example code is here on github. A demo is deployed here.

actions.js
export let fetchVehicles = params => ({
  type: "FETCH_VEHICLES",
  payload: fakeVehiclesApi(params),
  meta: { params } // <= NEW: add the API params to the action
});
reducers.js
// NEW: add this vehiclesCacheParams reducer
let vehiclesCacheParams = (state = null, action) => {
  switch (action.type) {
    case "FETCH_VEHICLES_FULFILLED":
      return action.meta.params;
    default:
      return state;
  }
};

The actions and reducers are updated to store the API parameters.

  • in the fetchVehicles action creator, the API params is added to the action
  • a new reducer, vehiclesCacheParams, is added which stores those params in Redux when the API succeeds
VehiclesList.js
class VehiclesList extends React.Component {
  _fetchData() {
    let { cacheParams, dispatch, query } = this.props;

    // NEW: add this "if" statement to check if API params have changed
    if (JSON.stringify(query) !== JSON.stringify(cacheParams)) {
      dispatch(actions.fetchVehicles(query));
    }
  }

  // ...the rest is the same as before
}

export default compose(
  withVehiclesRouter,
  connect(state => ({
    cacheParams: state.vehiclesCacheParams, // <= NEW line here
    isLoading: state.isLoading,
    vehicles: state.vehicles
  }))
)(VehiclesList);
  • in VehiclesList, the vehicleCacheParams state is added to the connect call
  • then an "if" condition is added around the fetchVehicles action dispatch. JSON.stringify is used to compare arguments that are objects or arrays.

Caching using redux-promise-memo

To avoid adding this boilerplate for every API, I abstracted the above idea into the redux-promise-memo library.

  • it uses a reducer to store the API input parameters like the manual solution
  • it provides a memoize function to wrap promise-based action creators (like those created with redux-promise-middleware above)
  • the memoize wrapper checks if the API input arguments have changed. If the input arguments match what is stored in Redux, the API call is skipped.
  • it also stores the "loading" and "success" state of the API. It skips the API call if the previous API call is loading or sucessful. But it re-fetches if there was an error.
  • it has an option to support multiple caches per API. If data is stored in a different places in Redux per set of input arguments, this option can be used.
  • it provides support for "invalidating" the cache using any Redux actions.
  • it provides support for other libraries besides redux-promise-middleware. Custom matchers for other libraries can be written. Examples of matchers are here

The full example code is here on github. A demo is deployed here.

Install redux-promise-memo

redux-promise-memo uses redux-thunk so it needs to be installed as well.

npm install redux-promise-memo redux-thunk
index.js
import thunk from "redux-thunk";

let store = createStore(reducer, applyMiddleware(thunk, promiseMiddleware()));

Add redux-thunk to redux middleware

reducers.js
// NEW: remove the vehiclesCacheParams reducer

// NEW: add the _memo reducer
import {
  createMemoReducer,
  reduxPromiseMiddlewareConfig
} from "redux-promise-memo";

let _memo = createMemoReducer(reduxPromiseMiddlewareConfig);

let rootReducer = combineReducers({
  _memo,
  isLoading,
  models,
  vehicle,
  vehicles
});
  • create the redux-promise-memo reducer and add it to the root reducer. IMPORTANT: the Redux state slice must be named _memo for it to work. I tried to create a Redux enhancer to handle this automatically, but did not get it to work reliably with other libraries.
  • remove the *CacheParams reducers to store API input params from our manual solution
actions.js
import { memoize } from "redux-promise-memo";

let _fetchModels = make => ({
  type: "FETCH_MODELS",
  payload: fakeModelsApi(make)
  // NEW: remove the API params from the action
});
// NEW: wrap the action creator with `memoize`
export let memoizedFetchModels = memoize(_fetchModels, "FETCH_MODELS");

let _fetchVehicle = vehicleId => ({
  type: "FETCH_VEHICLE",
  payload: fakeVehicleApi(vehicleId)
});
export let memoizedFetchVehicle = memoize(_fetchVehicle, "FETCH_VEHICLE");

let _fetchVehicles = params => ({
  type: "FETCH_VEHICLES",
  payload: fakeVehiclesApi(params)
});
export let memoizedFetchVehicles = memoize(_fetchVehicles, "FETCH_VEHICLES");
  • wrap the action creators with the memoize higher order function
  • specify a "key" to be used to separate parameters in the reducer. The action type is recommended to be used as the key.
VehiclesList.js
class VehiclesList extends React.Component {
  componentDidUpdate(prevProps) {
    // NEW: removed "if" condition here
    this._fetchData();
  }

  _fetchData() {
    let { dispatch, query } = this.props;
    // NEW: removed "if" condition here
    dispatch(actions.memoizedFetchVehicles(query));
  }

  // ...the rest is the same as before
}

export default compose(
  withVehiclesRouter,
  connect(state => ({
    // NEW: removed the cacheParams line here
    isLoading: state.isLoading,
    vehicles: state.vehicles
  }))
)(VehiclesList);
  • update components to remove "if" conditions because the library does the check
  • remove the use of state.vehicleCacheParams

This solution should behave similarly to the manual solution with less boilerplate code. In the development environment only, it also logs console messages showing if the API is requesting, loading, or cached.