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:
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:
setTimeout
includes its own clean up, so once the function is executed, the key is removed from the map.next
early if the action is not of type IScheduledAction
).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:
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 myAction
s to be scheduled:
export function myAction(order: Order) {
return {
type: "My Deferred Action",
payload: {
/*...*/
},
},
}
dispatch(schedule(myAction(order), 3000, "myDeferredAction"))
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.
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!