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:
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:
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.