Redux Debounce Middleware

In the last post I wrote about using middleware to isolate asynchronous effects from synchronous action dispatches. In this post I’ll illustrate a very simple redux debounce middleware function that works on the same principle.

Redux Debounce Middleware
Photo by John Torcasio / Unsplash

In the last post I wrote about using middleware to isolate asynchronous effects from synchronous action dispatches. In this post I’ll illustrate a very simple redux debounce middleware function that works on the same principle.

Debouncing

Imagine (if you will) that you have a form field that is meant to auto-save its contents. You could use a library function like lodash’s debounce() in the event handler. This is the most common approach

const AutoSaveTextArea = () => {
  const dispatch  = useDispatch();
  const onKeyDown = useCallback(
    _.debounce((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
      dispatch(autoSave(e.target.value)); 
    }, 200)
  }, []);
  
  return (
     <textarea onKeyDown={onKeyDown} />
  );
}

Works pretty well! You could even wrap the debounce in a custom hook so that the call signature was the same as useCallback with an extra parameter for the timeout. If the only actions you ever need to debounce come from react components, it's no big deal.

Now imagine that you're also getting (too many) loading progress actions piped down from a socket connection, then you have another kind of _.debounce call happening at a different call site. By using middleware to handle debounces, much like the way we handled asynchronous requests in the last tutorial, we can keep all of our application's (asynchronous) debounce logic in one place.

Decorating actions

Redux actions are messages. At minimum they have a type property, usually they have a payload and sometimes they include a meta property. The meta property is there to handle cross cutting concerns. For this exercise, we’ll define a DebouncedAction type with a debounce property on its meta object. Any action can be debounced, so we'll allow DebouncedAction to take a generic type argument.

type DebouncedAction<A extends AnyAction> = A & {
  meta: {
    debounce: number
  };
};

We still need a way to turn our autoSave action into a debounced autoSave action.

export const withDebounce = (debounce: number) => <A extends AnyAction>(action: A): DebouncedAction<A> => ({
  ...action,
  meta: {
    ...(action.meta ?? {}), 
    debounce,
  },
});

With this in mind, we rewrite our auto save text area

const debounce = withDebounce(200);
const AutoSaveTextArea = () => {
  const dispatch  = useDispatch();
  const onKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
    dispatch(
      debounce(autoSave(e.target.value))
    ); 
  }, []);
  
  return (
     <textarea onKeyDown={onKeyDown} />
  );
}

The middleware

Now it’s time to write some middleware to handle our newly debounce-enabled autoSave action.

First things first, we need a function to identify debounced actions so our middleware can handle them.

const isDebouncedAction = <A extends AnyAction>(action: A): action is DebouncedAction<A> =>
  typeof action?.meta?.debounce === 'number';

Now we’ll define a simple object to store all the pending actions. To avoid weirdness with type differences between Node and DOM versions of setTimeout we’ll use a utility type instead of number for the timer id.

type TimerId = ReturnType<typeof setTimeout>;
let pending: Record<string, TimerId> = {};

Now for the middleware function, which needs to:

  1. Check to see if an action has been debounced
  2. Make sure no other actions are pending, if so cancel them
  3. Wait for the debounce timeout, then release the action into the wild

Check if the action has been debounced

export const debouncer: Middleware = _store => next => action => {
  if (!isDebouncedAction(action)) {
    next(action);
    return;
  }

If we make it past that conditional, we know we have a DebouncedAction and can proceed.

Make sure no other actions are pending, if so cancel them

if (pending[action.type]) {
  clearTimeout(pending[action.type]);
}

Pretty straightforward. If the action type exists in our pending registry, we cancel the function with clearTimeout.1

Wait for the debounce timeout, then release the action into the wild

  pending[action.type] = setTimeout(() => {
    delete pending[action.type];

    next(action);
  }, action.meta.debounce);
};

Two things to note:

  • Use next instead of dispatch. Calling store.dispatch sends actions to the beginning of the middleware chain, which would cause an infinite loop. A rule of thumb for dispatching actions in middleware: if you're creating a new action use dispatch if you're sending the same action at a later time, use next.
  • We take care of cleanup in the callback function. No need to worry about that in the clearTimeout above because execution will fall through, overwriting the record no matter what.

Altogether now

export const debouncer: Middleware = _store => next => action => {
  if (!isDebouncedAction(action)) {
    next(action);
    return;
  }

  if (pending[action.type]) {
    clearTimeout(pending[action.type]);
  }

  pending[action.type] = setTimeout(() => {
    delete pending[action.type];

    next(action);
  }, action.meta.debounce);
};

Since middleware is executed in order, you'd locate debouncer first in the middleware array, so that actions are debounced before they can go too far up the chain.

The lodash thing was way simpler though, right?

Kinda, if we ignore the fact that _.debounce is roughly 400 lines of code. Even so, to the client dev this doesn’t feel much different. I prefer the middleware approach because it isolates the asynchronous setTimeout to a single call site. If that isn't important to you, then it's a wash.

Debounce is the simplest of the cross-cutting concerns that I use metadata decorators to manage, but other use cases include attaching jwts for actions that travel to the server, and receipts for actions that kick off other, related actions such as uploads emitting progress updates.

For instance a dispatch call as it exists in an application of mine

pipe(
  payload,
  CMD.updateQuestionBody,
  debounce,
  authenticate,
  debug,
  dispatch
);

This approach to managing events and actions has proven to be very pleasant to work with, and saved me a lot of debugging time. Your mileage, of course, may vary.

1. Using action types as keys can be fraught. For example, a save action that is simultaneously dispatched for multiple entities would clash. In that case, you'd define a function or convention to generate more specific keys for the pending registry. I use <actionType>/<entityId>

Subscribe to PFA INC / Scotty Weeks / Software Consulting

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe