The most elegant debounce code you'll ever write featuring XState
Tags: javascript, react
Published: 2020-02-19 02:25:00


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](https://codesandbox.io/s/xstate-debounce-example-7i9mw) 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.

```javascript
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](https://dev.to/codingdive/state-machine-advent-asynchronous-code-in-xstate-102p) before understanding what's going on below.

```javascript
// 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)**!

```javascript
// 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.

```jsx
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.

```javascript
// 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.

```javascript
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](https://xstate.js.org/docs/guides/delays.html#delay-expressions) to dynamically set the delay.

```javascript
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! ❤️