TypeOfNaN

Debouncing with Redux Middleware

Nick Scialli
October 20, 2019

Introduction

A common conundrum in today’s front-end framework world is knowing when and how to take certain asynchronous actions, such as persisting data to a backend. If we’re using a state management library like Redux, we might be further confused as to where without our Redux code we might put this logic.

A Concrete Scenario

For the purposes of this blog post, let’s assume we are using React with Redux and want to periodically save our state data to a backend. We have elected to use debouncing to do this, meaning we’d like to perform the save action after our state hasn’t changed for a certain amount of time.

Considering Our Options

So, what are our options when using React with Redux? I think the following list covers it:

  • Do it in a component - Have a component that subscribes to our state and, when it renders, do the debouncing/saving.
  • Do it in a redux action creator - Using something like thunk middleware, trigger the debounce function in an action create prior to dispatching the associated action.
  • Do it in a reducer - As you update your site data in the reducer, call a debounce function. (See note below for why I think this option is bad).
  • Do it in Redux middleware - Create a middleware that runs the debounce function anytime your state changes.

Note: I think all of these are actually legitimate ways except performing the save in a reducer. Reducers really should be pure functions and performing data fetching from within the reducer is a side effect.

Why I Like the Middleware Approach

As I mentioned above, I think most of these approaches could work fine, but I especially like the middleware approach. It nicely isolates your saving code, can selectively define which actions cause saving to start, doesn’t require installing thunk middleware if you’re not already using it, and doesn’t require you to include a component that exists only to handle saving.

The Implementation

First, we can create a saveDebounce function that will be called by our middleware. To implement debouncing, we’ll make use of setTimeout and clearTimeout.

let saveTimer;
let debounceTime = 10000; // 10 seconds

const saveDebounce = (data) => {
  if (saveTimer) {
    clearTimeout(saveTimer);
  }

  saveTimer = setTimeout(() => {
    // Use request library of choice here
    fetch('my-api-endpoint', {
      method: 'POST',
      body: JSON.stringify(data),
    });
  }, debounceTime);
};

Next, the actual middleware, which is pretty simple.

export const dataSaver = (store) => (next) => (action) => {
  saveDebounce(store.getState());
  return next(action);
};

As a user is modifying state, the saveDebounce function will clear any previous timeout and start a new one. Only when the user hasn’t changed state for 10 seconds will our fetch actually be called.

Finally, we need to register our middleware with Redux. This is done when we create our store.

import { createStore, combineReducers, applyMiddleware } from 'redux';
import { dataSaver } from '../middleware/dataSaver';

const allReducers = combineReducers(reducers);
const store = createStore(allReducers, applyMiddleware(dataSaver));

Some Optimizations

The above code should get you started pretty well, but we can make some optimizations.

Let’s stop calling getState so much

Calling getState on our store every time is unnecessarily and potentially expensive. Let’s only do that when we’re actually performing our fetch.

let saveTimer;
let debounceTime = 10000;

const saveDebounce = (store) => {
  if (saveTimer) {
    clearTimeout(saveTimer);
  }

  saveTimer = setTimeout(() => {
    fetch('my-api-endpoint', {
      method: 'POST',
      body: JSON.stringify(store.getState()),
    });
  }, debounceTime);
};

export const dataSaver = (store) => (next) => (action) => {
  saveDebounce(store);
  return next(action);
};

This of course means our saveDebounce function has to have knowledge of the store’s getState method. I think this trade-off is worth the performance boost.

Let’s only save a piece of our state

It seems unlikely we would really want to save the entire state object to a backend. More likely, we would just want to save a piece of our state object, which only gets updated by one or more actions.

Let’s pretend that we only want to save data when the userDetails part of state changes. Perhaps we know this happens only when the UPDATE_USER_DETAILS action is dispatched. Accordingly, we could make the following changes:

let saveTimer;
let debounceTime = 10000;

const saveDebounce = (store) => {
  if (saveTimer) {
    clearTimeout(saveTimer);
  }

  saveTimer = setTimeout(() => {
    fetch('my-api-endpoint', {
      method: 'POST',
      body: JSON.stringify(store.getState().userDetails),
    });
  }, debounceTime);
};

export const dataSaver = (store) => (next) => (action) => {
  if (action.type === 'UPDATE_USER_DETAILS') {
    saveDebounce(store);
  }
  return next(action);
};

Now, we only consider firing the save event if the UPDATE_USER_DETAILS action is dispatched. Furthermore, other parts of the state can be updating without cancelling our debounce!

Nick Scialli

Nick Scialli is a senior UI engineer at Microsoft.

© 2024 Nick Scialli