Debouncing is the practice of delaying the execution of a resource or time-intensive task long enough to make the application feel very responsive. This is typically done by reducing the number of times the task is executed.
Whether you're filtering a giant list or simply want to wait a few seconds until the user has stopped typing before sending a request, chances are that you'll have to deal with debouncing one way or another especially if you happen to be a front end engineer.
I claim that handling this with the popular state management, state machine and statechart library XState is as good as it can get. Before you can convince yourself, let's quickly determine the best possible debounce user experience before translating it into code.
Good debounce logic should:
- give users instant feedback about what they're doing. We might want to wait a second before sending a request to the server but we do not ever want to delay the input of the user by a second as they'll either think our app is broken or their computer is lagging.
- have a way to cancel the resource-intensive action. Not only do we want to cancel it when the user makes another keystroke before our debounce timer has finished, but we also want the ability to cancel the queued action when changing state. For example, there is no point in filtering a giant list when we've already transitioned into a state that doesn't show the list anymore.
- allow us to set the timed delay dynamically. This could allow us to make the delay longer for mobile users as the average typing speed decreases.
With our optimal user experience out of the way, let's dive into the code.
Check out the codesandbox and read below for detailed explanations.
Let's write an app that displays tasty plants with the ability to filter them.
Since there are soo many tasty plants, we are expecting the server to take quite a long time. Therefore, we'll need to debounce the user input before the server starts filtering.
const tastyPlants = [
"seeds 🌱",
"mushrooms 🍄",
"nuts 🥜",
"broccoli 🥦",
"leafy greens🥬"
];
// For the extended state of the machine, we want to store the user input and the plants to render.
const machineContext = {
input: "",
filteredTastyPlants: []
};
In other words, we don't want to send a server request on every keystroke; instead, we want to add a minor delay of 450 milliseconds. Also, instead of using an actual HTTP request, we are going to keep things local and just use a timeout.
The code that is responsible for performing the (fake) slow filter operation might look like this:
If you're new to asynchronous code in statecharts, you may want to check out this blog post before understanding what's going on below.
// inside our machine
apiClient: {
initial: "idle",
on: {
slowFilter: {
target: ".filtering"
}
},
states: {
idle: {},
filtering: {
invoke: {
id: "long-filter-operation",
src: (context, event) =>
new Promise(resolve =>
setTimeout(
() =>
resolve(
tastyPlants.filter(plant => plant.includes(context.input))
),
1500
)
),
onDone: {
target: "idle",
actions: assign({
filteredTastyPlants: (context, event) => event.data
})
}
}
}
}
},
We aren't doing anything special here just yet. We pretend that our server takes 1500 milliseconds until it completes the filtering and upon resolving, we can ultimately assign the filtered plants to our filteredTastyPlants
context.
You might have noticed that within the slowFilter
event, we haven't actually assigned the input to the state machine yet. As you'll see shortly, the trick to make debouncing work in XState is to use two events instead of one.
Responsiveness
For instant feedback, which was our very first constraint, we'll define an extra event that assigns the input to the machine context. This event will also have the responsibility of sending the slowFilter
event after a delay of 450ms. That's right. A machine can send events to itself. Let's see it in action(s)!
// import { actions, assign } from 'xstate'
// const { send } = actions
// within our machine
on: {
filter: {
actions: [
assign({
input: (context, event) => event.input
}),
send("slowFilter", {
delay: 450,
});
];
}
}
The above code guarantees that the slowFilter
event is called 450ms after every keystroke. Cool! In our component, we treat the slowFilter
event like an internal event of the machine, meaning we'll only ever work with the filter
event as seen in the example below.
const [state, send] = useMachine(filterPlantsMachine).
return (
<input value={state.context.input} onChange={(e) => void send({type: 'filter', input: e.target.value})}>
// render state.context.filteredTastyPlants
)
Cancellation
To work towards our second constraint, we now need a way to cancel the slowFilter
event that is about to be sent. We can do so by giving the event an id, then canceling the event by the same id using the cancel
action creator.
// import { actions, assign } from 'xstate'
// const { send, cancel } = actions
// within our machine
on: {
filter: {
actions: [
assign({
input: (context, event) => event.input
}),
cancel('debounced-filter'),
send("slowFilter", {
delay: 450,
id: "debounced-filter"
});
];
}
}
Because the above code cancels and resends the event
on every keystroke, it'll only be sent once the user has stopped typing for at least 450ms. Pretty elegant right? For even better readability, we can expressively name the actions.
on: {
filter: {
actions: [
'assignInput',
'cancelSlowFilterEvent',
'sendSlowFilterEventAfterDelay'
];
}
}
// ...
// pass actions as config to the second argument of the Machine({}, {/* config goes here */}) function.
{
actions: {
assignInput: assign({
input: (context, event) => event.input,
}),
cancelSlowFilterEvent: cancel('debounced-filter'),
sendSlowFilterEventAfterDelay: send('slowFilter', {
delay: 450,
id: 'debounced-filter',
}),
},
}
Dynamically set debounce delay
Last but not least, to provide the best possible user experience we may want to dynamically change the delay. To account for the typing speed decrease in words per minute when going from desktop to phone, let's only start the filtering 800ms after the last keystroke when the user is on their phone.
After adding an isPhone
boolean to our context (we could also pass it via the event), we can use a delay expression to dynamically set the delay.
sendSlowFilterEventAfterDelay: send('slowFilter', {
delay: (context, event) => context.isPhone ? 800 : 450,
id: 'debounced-filter',
}),
Let me know in the comments what you think and if you have any questions. Happy debouncing! ❤️
Top comments (5)
Hello beautiful people!
Based on some feedback, I have written an improved version that debounces with state transitions. The advantage is that it cancels existing requests (by ignoring their result) and in XState version 5, AbortController support will be built in, meaning we get to actually cancel the promise natively! You can find the improved CodeSandbox here
codesandbox.io/s/xstate-transition...
The way debouncing is handled in this blog post, is still my preferred way when in need to debounce synchronous code. E.g when you have a really big array and need to do some computation on it, delayed events are the way to go. For async logic, I now recommend you to follow the Codesandbox and just use transitions.
I'm hoping to update the blog post too at some point.
You should checkout Dan Abramov's Time-slicing talk from a few years ago, he does a great demo about synv vs debounced vs concurrent.
youtube.com/watch?v=nLF0n9SACd4
I watched it and enjoyed it a lot. This post is about XState not concurrent react though. Same debounce logic can be done in XState with vanilla JS.
Very nice! I love reading about "simple", real world use cases being improved by state machines/statecharts.
Worked like a charm. Thank you!