Debounce on inputs in React
In this post, I want to discuss debouncing. In other words - deliberately delaying the effects of user's interactions with inputs.
Event handling
In React (or in JavaScript, in general), changes on inputs are usually handled using some kind of event listeners, like onChange
callbacks. Consider a plain, old text input: every keystroke fires up an associated handler, passing an event object containing the latest value as an argument.
const handler = (event: React.ChangeEvent<HTMLInputElement>) => {
/*..*/
};
/*..*/
<input onChange={handler} />;
While it's great for keeping up the state of the app in sync, is it always a desired behaviour? Should each atomic change trigger an effect in every scenario?
Use case
Imagine that you need to implement a text search component for your application. You expect the user to type in the search query to a simple input. For a better UX, you decide to skip the "submit" button but instead send the query up the API stream as the user types. This is normal behavior for a modern UI.
It's all great until you realize that every keystroke results in an XHR (HTTP) request being sent from the browser to the backend. Multiply that by a number of actors using your application simultaneously, and voila, you have just accidentally invented what looks like a DDoS (distributed denial of service) attack. Best case scenario - at some point of "flooding" the server with hundreds of requests in a relatively short time window, some clients' IPs would become blacklisted by IDS/IPS protection mechanisms. Worst case - the entire backend service goes down.
❗️ Moreover, issuing that many asynchronous requests in a short time window greatly increases the risk of responses coming back out of order.
From the user's point of view, this race condition leads to very annoying inconsistencies in the UI, e.g. older requests resolving as the last ones and the UI effectively displaying results for an outdated state of the query.
Partner with Typesctript, React and Next.js experts to make an impact with beautiful and functional products built with rock-solid development practices. Explore the offer >>
Non-debounced effects
The animation below shows the "flood" of events in non-debounced input:
Each keystroke is handled separately. The "debugger" window logs every "effect", simulating queries that could be sent to the backend server. Clearly, this is a sub-optimal solution.
Proper event handling
In this example, I'm using lodash.debounce
(which is like a standalone function extracted from the "parent" lodash library) to create a debounced version of the event handler:
import debounce from "lodash.debounce";
const DEBOUNCE_TIME_MS = 300;
/*..*/
const onNameChange = useMemo(
() =>
debounce((event: React.ChangeEvent<HTMLInputElement>) => {
setEffects(/*..*/);
}, DEBOUNCE_TIME_MS),
[]
);
Here, debounce
accepts two arguments: the function
(handler), and the wait
time.
ℹ️ Precisely,
debounce
also accepts a third argument,options
, which is used to e.g. specify whether the handler should be called at the leading and/or trailing end of the timeout. However, this argument is optional and can be skipped.
Additionally, it's all wrapped in React's useMemo
hook in order to avoid unnecessary re-creation of the function on every render.
Here's the animation showing off the behavior of the debounced event handler.
The difference should be easy to spot: as long as the user was typing (with a "gap" between keystrokes shorter that DEBOUNCE_TIME_MS
), the effect was delayed.
See how they took two short breaks while typing somewhere near the end of the sentence (plus the third break - when they finished)? This is where the debounced effects kicked in. The debugger logged only 3 effects, which is a significant difference over roughly 13x more events in a non-debounced version!
ℹ️ Remember - the "flow" of a debounced event handler goes roughly like this:
- listen to user-generated events and "accumulate" them;
- wait for some period of time of "silence" (absence of events), usually around 300-500 milliseconds;
- apply the effect with the "latest" payload.
Cleaning up
It is also worth defining a logic for a clean-up, should the component unmount (e.g. because the user navigated out of the route). This is why the useEffect
returning a function should be added, calling a .cancel()
method on the debounced event handler.
useEffect(() => {
return () => {
onNameChange.cancel();
};
}, [onNameChange]);
💡 Here's why: imagine that the
DEBOUNCE_TIME_MS
is long enough (eg. 1000-2000ms) to create such a condition:
- user starts typing;
- user immediately navigates out of the current route, so that the search component unmounts before the debounced effect is applied
This hypothetical scenario could result in a memory leak, precisely an attempt to update component's state after it's already unmounted. That is why it is a good practice to remember to clearall potential scheduled tasks that we know would not be handled correctly.
Every function returned by invoking
debounce(..)
has few special properties attached, hence the.cancel()
can be invoked.
Take away notes
For a great user experience, modern interfaces should take performance into account. Optimizing event handling and effects is one of many aspects that engineers need to remember.
This doesn't mean, that you should "fix" all inputs in every of your applications - for example, it's still fine for regular text input to update the in-memory state of the form on every keystroke. Always think about the effects of handling events on different frequency thresholds and decide which strategy is appropriate for the case.
ℹ️ For curious readers, here you should find a working example of a debounced event handler along with a full codebase.