United Kingdom: +44 (0)208 088 8978

Debouncing request in Feliz using custom hooks

In this blog post, we explore reducing network load and improving user experience by introducing a custom React.useDebouned hook

UIs that immediately act on user input can be very nice, but what if they become too eager, or don't deal with the data nicely? That's the problem I ran into while working on a Feliz app earlier this week.

Take, for example, a search box that automatically searches as the user types. This feature allows for a very minimal UI and ensures that the user has the search results available right away. However, it comes at a cost; every keystroke results in a search action, and depending on the implementation, may even send off an HTTP request! This can lead to performance issues, increased server load, and a poor user experience due to rapid flickering of content.

This is where debouncing comes in. Debouncing is a programming practice that limits the rate at which a function gets called. Instead of executing the function immediately, we wait for a brief pause in the input before proceeding. Think of it like a lift: instead of closing the doors immediately after someone enters, it waits a few seconds to see if anyone else is coming. This helps reduce unnecessary operations and creates a smoother user experience.

Jokes Galore!

To illustrate the problem of repeated requests, I made a very small web app using Fable and Feliz that calls an API that allows you to search for jokes. You can also find this code on GitHub, as part of a complete, runnable website.

let searchJokes searchTerm : Fable.Core.JS.Promise<string option> =
    promise {
        let! result =
            tryFetch
                $"https://v2.jokeapi.dev/joke/Any?blacklistFlags=nsfw,religious,political,racist,sexist,explicit&format=txt&contains={searchTerm}"
                []

        match result with
        | Ok response ->
            let! text = response.text ()
            return Some text
        | Error _ -> return None
    }

[<ReactComponent>]
let Joker () =

    let (joke, setJoke) = React.useState (Some "Search to find a funny joke!")

    let updateJoke = searchJokes >> Promise.iter setJoke

    React.fragment
        [

          Html.form
              [ Html.label [ prop.text "Search for a joke!" ]
                Html.input [ prop.onChange (fun (text: string) -> updateJoke text) ] ]

          Html.p (
              match joke with
              | Some j -> j
              | None -> "No jokes found! please try again!"
          ) ]

If you try out this version of the app, you'll notice jokes flashing by as you type, and if you open the network tab, you see a lot of requests being made:

A screen recording of the app and the browser network panel, showing that the UI is flickery and a lot of network requests are made

Debouncing

A simple way to reduce the flicker, and send fewer HTTP requests, is debouncing; instead of sending requests out immediately, we wait for a bit to see if any other requests are being made. If so, we cancel the previous request. Once the user stops giving input for half a second, we'll let that request through. Let's implement this.

To achieve the required behavior we need to:

  • Be able to start a request with a short delay
  • Be able to cancel this request while it's delayed

Let's start with the delay; we create a function that takes the delay duration, and a function to execute after this delay:

let debounce (delay: int) (f: _ -> ()) =
    fun args -> async {
        do! Async.Sleep delay

        f args
    }
    |> Async.Start

So far we have not really gained anything; all requests will still happen. Let's use .NET's CancellationTokenSource abstraction to allow ourselves to cancel the previous request. We'll have to use the React.useStateWithUpdater function to keep track of this state between React rerenders. Because we call a hook inside the function, it essentially becomes a hook itself, so we rename it to useDebounced:

let useDebounced (delay: int) f =
    let _, updateCancellationSource =
        React.useStateWithUpdater (None: CancellationTokenSource option)

    fun args ->
        let cts = new CancellationTokenSource()

        // If a previous run exists, cancel it
        updateCancellationSource (fun maybeExistingCts ->
            match maybeExistingCts with
            | None -> ()
            | Some existingCts -> existingCts.Cancel()

            Some cts)

        async {
            do! Async.Sleep delay

            f args
        }
        |> Async.Start

Great! We now know whether our request is allowed to proceed, but we don't do anything with that information. Let's check the state of our CancellationTokenSource after the delay has ended, and cancel our call if requested to do so; we'll also dispose of the CancellationTokenSource, so any related resources can be released:

let useDebounced (delay: int) f =
    // this value is only used to affect the behavior of dispatchUpdate where it's accessed via the updater, so can be discarded here
    let _, updateCancellationSource =
        React.useStateWithUpdater (None: CancellationTokenSource option)

    fun args ->
        let cts = new CancellationTokenSource()

        // Update the cancellation source, canceling any existing request
        updateCancellationSource (fun maybeExistingCts ->
            match maybeExistingCts with
            | None -> ()
            | Some existingCts -> existingCts.Cancel()

            Some cts)

        async {
            do! Async.Sleep delay

            // Check if the request has been canceled
            if not cts.IsCancellationRequested then
                f args

            // Dispose of the cancellation token source
            cts.Dispose()
        }
        |> Async.Start

And there we have it, a custom React hook that allows us to debounce actions. Using it in our app is easy:

[<ReactComponent>]
let Joker () =

    let (joke, setJoke) = React.useState (Some "Search to find a funny joke!")

    // Use the debounced hook with a 500ms delay
    let updateJoke = React.useDebounced 500 (searchJokes >> Promise.iter setJoke)

    ...

Let's see it in action:

A screen recording of the app and the browser network panel, showing that the UI updates more smoothly and fewer requests are made

Conclusion

This useDebounced hook creates a clean and easy way to add debouncing to your Feliz apps. It helps prevent unnecessary API calls, reduces server load, and provides a smoother user experience by eliminating the rapid flickering of content. By using React's state management and .NET's CancellationTokenSource, we've created a reusable solution that can be applied to any scenario where you need to throttle user input or other frequent events.