redux: scheduling dispatches with middleware

2022-03-02

 | 

~4 min read

 | 

632 words

Recently, I was working on a task where I wanted to update the UI for an app automatically to reflect some state. While that sounds like Redux’s bread and butter, there was a complication in how we wanted to handle a “syncing” effect so as not to create a jarring user experience.

The solution the team came up with involved scheduling a dispatch for the future with a schedule-middleware function:

schedule-middleware.ts
const scheduledActions = new Map<string, ReturnType<typeof setTimeout>>()

export interface IScheduledAction {
  schedule: SchedulingDetails
}

export type OneShotSchedulingDetails = {
  kind: "oneShot"
  delay: number
  key?: string
}

export type SchedulingDetails = OneShotSchedulingDetails

function isScheduledAction(action: any): action is IScheduledAction {
  return "schedule" in action
}

export default function () {
  return (next) => (action) => {
    if (!isScheduledAction(action)) {
      return next(action)
    } else {
      switch (action.schedule.kind) {
        case "oneShot":
          const { delay, key } = action.schedule

          // If no scheduleKey is provided, there will be no pre-empting of existing instance
          // e.g. can't reset existing timer back to default

          if (key && scheduledActions.has(key)) {
            const existingTimer = scheduledActions.get(key)
            clearTimeout(existingTimer)
            scheduledActions.delete(key)
          }

          const newTimer = setTimeout(() => {
            next(action)
            if (key) {
              scheduledActions.delete(key)
            }
          }, delay)

          if (key) {
            scheduledActions.set(key, newTimer)
          }
          break
        default:
          next(action)
      }
    }
  }
}

Once we have the middleware written, we need to apply it. How to do this depends on which flavor of Redux we’re using, but the basics are in the Redux docs.

There are a few nice things about this solution that are worth pointing out:

  1. There’s a single map that keeps a master schedule of all of the actions that have been deferred.
  2. The setTimeout includes its own clean up, so once the function is executed, the key is removed from the map.
  3. The code is aborted early for cases where we are not scheduling the action (i.e., we call next early if the action is not of type IScheduledAction).
  4. If no key is provided, the action is scheduled, but not placed into the map and therefore there’s no need to have cleanup.

Now, that we have our middleware, how do actually invoke this?

export function myAction(order: Order) {
  return {
    type: "My Deferred Action",
    schedule: {
      kind: "oneShot",
      delay: 3000,
      key: "myDeferredAction",
    },
    payload: {
      /*...*/
    },
  }
}

This works because the only requirement of an action is that it return a plain object. While the convention is to use the keys type and payload, there’s nothing saying we can’t add our own keys. It’s worth being careful here, however, as we want to avoid conflicts.

One thing we can do to make this a little more ergonomic is to write a helper function that wraps our action for us:

schedule.ts
export function schedule(action: any, delay: number, key?: string) {
  return {
    ...action,
    schedule: { type: "oneShot", delay, key },
  }
}

The following is equivalent to our previous call:

export function myAction(order: Order) {
  return schedule(
    {
      type: "My Deferred Action",
      payload: {
        /*...*/
      },
    },
    3000,
    "myDeferredAction",
  )
}

Which would be used like:

dispatch(myAction(order))

Similarly, you could lift schedule out to force all dispatched myActions to be scheduled:

export function myAction(order: Order) {
  return {
      type: "My Deferred Action",
      payload: {
        /*...*/
      },
    },
}

dispatch(schedule(myAction(order), 3000, "myDeferredAction"))

Conclusion

Most of the time you’ll want Redux to operate synchronously and process your actions as expected. However, as we’ve shown in this post, there are some reasons why you may need to defer some actions and this post walks through one way to think about doing that.

Other resources

  1. Schedule Dispatch In Reducer | StackOverflow
  2. Can I Dispatch An Action In Reducer | StackOverflow
  3. Dispatching Actions From Inside of Reducers | Lazamar


Hi there and thanks for reading! My name's Stephen. I live in Chicago with my wife, Kate, and dog, Finn. Want more? See about and get in touch!