Table of Contents
- What is debouncing and throttling
- Debounced callback in React: dealing with re-renders
- Debounced callback in React: dealing with state inside
- Before you bounce
There is more
Nadia Makarevich
How to debounce and throttle in React without losing your mind
Deep dive into debounce and throttle in React.
What is debounce and throttle, how to use them in React properly, how to avoid breaking them when state and re-renders are involved.
When talking about performance in general, and especially in React, the words “immediate”, “fast”, “as soon as possible” instantly come to mind. Is it always true though? Contrary to common wisdom, sometimes it’s actually good to slow down and think about life. Slow and steady wins the race, you know 😉
The last thing that you want is an async search functionality to crash your web server, just because a user is typing too fast and you send requests on every keystroke. Or your app to become unresponsive or even crash your browser window during scroll, just because you’re doing expensive calculations on every scroll event fired (there can be 30-100 per second of those!).
This is when such “slow down” techniques as “throttle” and “debounce” come in handy. Let's take a brief look at what they are (in case you haven’t heard of them yet), and then focus on how to use them in React correctly - there are a few caveats there that a lot of people are not aware of!
Side note: I’m going to use lodash library’s debounce and throttle functions. Techniques and caveats, described in the article, are relevant to any library or implementation, even if you decide to implement them by yourself.
What is debouncing and throttling
Debouncing and throttling are techniques that allow us to skip function execution if that function is called too many times over a certain time period.
Imagine, for example, that we’re implementing a simple asynchronous search functionality: an input field, where a user can type something, text that they type is sent to the backend, which in turn returns relevant search results. We can surely implement it “naively”, just an input field and onChange
callback:
const Input = () => {const onChange = (e) => {// send data from input field to the backend here// will be triggered on every keystroke}return <input onChange={onChange} />}
But a skilled typer can type with the speed of 70 words per minute, which is roughly 6 keypresses per second. In this implementation, it will result in 6 onChange
events, i.e. 6 requests to the server per second! Sure your backend can handle that?
Instead of sending that request on every keypress, we can wait a little bit until the user stops typing, and then send the entire value in one go. This is what debouncing does. If I apply debounce to my onChange
function, it will detect every attempt I make to call it, and if the waiting interval hasn’t passed yet, it will drop the previous call and restart the “waiting” clock.
const Input = () => {const onChange = (e) => {// send data from input field to the backend here// will be triggered 500 ms after the user stopped typing}const debouncedOnChange = debounce(onChange, 500);return <input onChange={debouncedOnChange} />}
Before, if I was typing “React” in the search field, the requests to the backend would be on every keypress instantaneously, with the values “R”, “Re”, “Rea”, “Reac”, “React”. Now, after I debounced it, it will wait 500 ms after I stopped typing “React” and then send only one request with the value “React”.
Underneath, debounce
is just a function, that accepts a function, returns another function, and has a tracker inside that detects whether the passed function was called sooner than the provided interval. If sooner - then skip the execution and re-start the clock. If the interval passed - call the passed function. Essentially it’s something like this:
const debounce = (callback, wait) => {// initialize the timerlet timer;...// lots of code involving the actual implementation of timer// to track the time passed since the last callback call...const debouncedFunc = () => {// checking whether the waiting time has passedif (shouldCallCallback(Date.now())) {callback();} else {// if time hasn't passed yet, restart the timertimer = startTimer(callback);}}return debouncedFunc;}
The actual implementation is of course a bit more complicated, you can check out lodash debounce code to get a sense of it.
Throttle
is very similar, and the idea of keeping the internal tracker and a function that returns a function is the same. The difference is that throttle
guarantees to call the callback function regularly, every wait
interval, whereas debounce
will constantly reset the timer and wait until the end.
The difference will be obvious if we use not an async search example, but an editing field with auto-save functionality: if a user types something in the field, we want to send requests to the backend to save whatever they type “on the fly”, without them pressing the “save” button explicitly. If a user is writing a poem in a field like that really really fast, the “debounced” onChange
callback will be triggered only once. And if something breaks while typing, the entire poem will be lost. “Throttled” callback will be triggered periodically, the poem will be regularly saved, and if a disaster occurs, only the last milliseconds of the poem will be lost. Much safer approach.
You can play around with “normal” input, debounced input, and throttled input fields in this example:
Debounced callback in React: dealing with re-renders
Now, that it’s a bit more clear what are debounce and throttle, why we need them, and how they are implemented, it’s time to dig deep into how they should be used in React. And I hope you don’t think now “Oh c’mon, how hard can it be, it’s just a function”, do you? It’s React we’re talking about, when it was ever that easy? 😅
First of all, let's take a closer look at the Input
implementation that has debounced onChange
callback (from now forward I’ll be using only debounce
in all examples, every concept described will be relevant for throttle as well).
const Input = () => {const onChange = (e) => {// send data from input to the backend here}const debouncedOnChange = debounce(onChange, 500);return <input onChange={debouncedOnChange} />}
While the example works perfectly, and seems like a regular React code with no caveats, it unfortunately has nothing to do with real life. In real life, more likely than not, you’d want to do something with the value from the input, other than sending it to the backend. Maybe this input will be part of a large form. Or you’d want to introduce a “clear” button there. Or maybe the input
tag is actually a component from some external library, which mandatory asks for the value
field.
What I’m trying to say here, at some point you’d want to save that value into state, either in the Input
component itself, or pass it to parent/external state management to manage it instead. Let’s do it in Input
, for simplicity.
const Input = () => {// adding state for the valueconst [value, setValue] = useState();const onChange = (e) => {};const debouncedOnChange = debounce(onChange, 500);// turning input into controlled component by passing value from state therereturn <input onChange={debouncedOnChange} value={value} />}
I added state value
via useState
hook, and passed that value to input
field. One thing left to do is for input
to update that state in its onChange
callback, otherwise, input won’t work. Normally, without debounce, it would be done in onChange
callback:
const Input = () => {const [value, setValue] = useState();const onChange = (e) => {// set state value from onChange eventsetValue(e.target.value);};return <input onChange={onChange} value={value} />}
I can’t do that in onChange
that is debounced: its call is by definition delayed, so value
in the state won’t be updated on time, and input
just won’t work.
const Input = () => {const [value, setValue] = useState();const onChange = (e) => {// just won't work, this callback is debouncedsetValue(e.target.value);};const debouncedOnChange = debounce(onChange, 500);return <input onChange={debouncedOnChange} value={value} />}
I have to call setValue
immediately when input
calls its own onChange
. This means I can’t debounce our onChange
function anymore in its entirety and only can debounce the part that I actually need to slow down: sending requests to the backend.
Probably something like this, right?
const Input = () => {const [value, setValue] = useState();const sendRequest = (value) => {// send value to the backend};// now send request is debouncedconst debouncedSendRequest = debounce(sendRequest, 500);// onChange is not debounced anymore, it just calls debounced functionconst onChange = (e) => {const value = e.target.value;// state is updated on every value change, so input will worksetValue(value);// call debounced request heredebouncedSendRequest(value);}return <input onChange={onChange} value={value} />}
Seems logical. Only… It doesn’t work either! Now the request is not debounced at all, just delayed a bit. If I type “React” in this field, I will still send all “R”, “Re”, “Rea”, “Reac”, “React” requests instead of just one “React”, as properly debounced func should, only delayed by half a second.
Check out both of those examples and see for yourself. Can you figure out why?
The answer is of course re-renders (it usually is in React 😅). As we know, one of the main reasons a component re-renders is a state change. With the introduction of state to manage value, we now re-render the entire Input
component on every keystroke. As a result, on every keystroke, we now call the actual debounce
function, not just the debounced callback. And, as we know from the previous chapter, the debounce
function when called, is:
- creating a new timer
- creating and returning a function, inside of which the passed callback will be called when the timer is done
So when on every re-render we’re calling debounce(sendRequest, 500)
, we’re re-creating everything: new call, new timer, new return function with callback in arguments. But the old function is never cleaned up, so it just sits there in memory and waits for its own timer to pass. When its timer is done, it fires the callback function, and then just dies and eventually gets cleaned up by the garbage collector.
What we ended up with is just a simple delay
function, rather than a proper debounce
. The fix for it should seem obvious now: we should call debounce(sendRequest, 500)
only once, to preserve the inside timer and the returned function.
The easiest way to do it would be just to move it outside of Input
component:
const sendRequest = (value) => {// send value to the backend};const debouncedSendRequest = debounce(sendRequest, 500);const Input = () => {const [value, setValue] = useState();const onChange = (e) => {const value = e.target.value;setValue(value);// debouncedSendRequest is created once, so state caused re-renders won't affect it anymoredebouncedSendRequest(value);}return <input onChange={onChange} value={value} />}
This won’t work, however, if those functions have dependencies on something that is happening within component’s lifecycle, i.e. state or props. No problem though, we can use memoization hooks to achieve exactly the same result:
const Input = () => {const [value, setValue] = useState("initial");// memoize the callback with useCallback// we need it since it's a dependency in useMemo belowconst sendRequest = useCallback((value: string) => {console.log("Changed value:", value);}, []);// memoize the debounce call with useMemoconst debouncedSendRequest = useMemo(() => {return debounce(sendRequest, 1000);}, [sendRequest]);const onChange = (e) => {const value = e.target.value;setValue(value);debouncedSendRequest(value);};return <input onChange={onChange} value={value} />;}
Here is the example:
Now everything is working as expected! Input
component has state, backend call in onChange
is debounced, and debounce actually behaves properly 🎉
Until it doesn’t…
Debounced callback in React: dealing with state inside
Now to the final piece of this bouncing puzzle. Let’s take a look at this code:
const sendRequest = useCallback((value: string) => {console.log("Changed value:", value);}, []);
Normal memoized function, that accepts value
as an argument and then does something with it. The value is coming directly from input
through debounce function. We pass it when we call the debounced function within our onChange
callback:
const onChange = (e) => {const value = e.target.value;setValue(value);// value is coming from input change event directlydebouncedSendRequest(value);};
But we have this value in state as well, can’t I just use it from there? Maybe I have a chain of those callbacks and it's really hard to pass this value over and over through it. Maybe I want to have access to another state variable, it wouldn’t make sense to pass it through a callback like this. Or maybe I just hate callbacks and arguments, and want to use state just because. Should be simple enough, isn’t it?
And of course, yet again, nothing is as simple as it seems. If I just get rid of the argument and use the value
from state, I would have to add it to the dependencies of useCallback
hook:
const Input = () => {const [value, setValue] = useState("initial");const sendRequest = useCallback(() => {// value is now coming from stateconsole.log("Changed value:", value);// adding it to dependencies}, [value]);}
Because of that, sendRequest
function will change on every value change - that’s how memoization works, the value is the same throughout the re-renders until the dependency changes. This means our memoized debounce call will now change constantly as well - it has sendRequest
as a dependency, which now changes with every state update.
// this will now change on every state update// because sendRequest has dependency on stateconst debouncedSendRequest = useMemo(() => {return debounce(sendRequest, 1000);}, [sendRequest]);
And we returned to where we were the first time we introduced state to the Input
component: debounce turned into just delay.
Is there anything that can be done here?
If you search for articles about debouncing and React, half of them will mention useRef
as a way to avoid re-creating the debounced function on every re-render. useRef
is a useful hook that allows us to create ref
- a mutable object that is persistent between re-renders. ref
is just an alternative to memoization in this case.
Usually, the pattern goes like this:
const Input = () => {// creating ref and initializing it with the debounced backend callconst ref = useRef(debounce(() => {// this is our old "debouncedSendRequest" function}, 500));const onChange = (e) => {const value = e.target.value;// calling the debounced functionref.current();};}
This might be actually a good alternative to the previous solution based on useMemo
and useCallback
. I don’t know about you, but those chains of hooks give me a headache and make my eye twitch. Impossible to read and understand! The ref-based solution seems much easier.
Unfortunately, this solution will only work for the previous use-case: when we didn’t have state inside the callback. Think about it. The debounce
function here is called only once: when the component is mounted and ref
is initialized. This function creates what is known as “closure”: the outside data that was available to it when it was created will be preserved for it to use. In other words, if I use state value
in that function:
const ref = useRef(debounce(() => {// this value is coming from stateconsole.log(value);}, 500));
the value will be “frozen” at the time the function was created - i.e. initial state value. When implemented like this, if I want to get access to the latest state value, I need to call the debounce
function again in useEffect
and re-assign it to the ref. I can’t just update it. The full code would look something like this:
const Input = () => {const [value, setValue] = useState();// creating ref and initializing it with the debounced backend callconst ref = useRef(debounce(() => {// send request to the backend here}, 500));useEffect(() => {// updating ref when state changesref.current = debounce(() => {// send request to the backend here}, 500);}, [value]);const onChange = (e) => {const value = e.target.value;// calling the debounced functionref.current();};}
But unfortunately, this is no different than useCallback
with dependencies solution: the debounced function is re-created every time, the timer inside is re-created every time, and debounce is nothing more than re-named delay
.
See for yourself:
But we’re actually onto something here, the solution is close, I can feel it.
One thing that we can take advantage of here, is that in Javascript objects are not immutable. Only primitive values, like numbers or references to objects, will be “frozen” when a closure is created. If in our “frozen” sendRequest
function I will try to access ref.current
, which is by definition mutable, I will get the latest version of it all the time!
Let’s recap: ref
is mutable; I can only call debounce
function once on mount; when I call it, a closure will be created, with primitive values from the outside like state
value "frozen" inside; mutable objects will not be “frozen”.
And hence the actual solution: attach the non-debounced constantly re-created sendRequest
function to the ref; update it on every state change; create “debounced” function only once; pass to it a function that accesses ref.current
- it will be the latest sendRequest with access to the latest state.
Thinking in closures breaks my brain 🤯, but it actually works, and easier to follow that train of thought in code:
const Input = () => {const [value, setValue] = useState();const sendRequest = () => {// send request to the backend here// value is coming from stateconsole.log(value);};// creating ref and initializing it with the sendRequest functionconst ref = useRef(sendRequest);useEffect(() => {// updating ref when state changes// now, ref.current will have the latest sendRequest with access to the latest stateref.current = sendRequest;}, [value]);// creating debounced callback only once - on mountconst debouncedCallback = useMemo(() => {// func will be created only once - on mountconst func = () => {// ref is mutable! ref.current is a reference to the latest sendRequestref.current?.();};// debounce the func that was created once, but has access to the latest sendRequestreturn debounce(func, 1000);// no dependencies! never gets updated}, []);const onChange = (e) => {const value = e.target.value;// calling the debounced functiondebouncedCallback();};}
Now, all we need to do is to extract that mind-numbing madness of closures in one tiny hook, put it in a separate file, and pretend not to notice it 😅
const useDebounce = (callback) => {const ref = useRef();useEffect(() => {ref.current = callback;}, [callback]);const debouncedCallback = useMemo(() => {const func = () => {ref.current?.();};return debounce(func, 1000);}, []);return debouncedCallback;};
And then our production code can just use it, without the eye-bleeding chain of useMemo
and useCallback
, without worrying about dependencies, and with access to the latest state and props inside!
const Input = () => {const [value, setValue] = useState();const debouncedRequest = useDebounce(() => {// send request to the backend// access to latest state hereconsole.log(value);});const onChange = (e) => {const value = e.target.value;setValue(value);debouncedRequest();};return <input onChange={onChange} value={value} />;}
Isn’t that pretty? You can play around with the final code here:
Before you bounce
Hope this bouncing around was useful for you and now you feel more confident in what debounce and throttle are, how to use them in React, and what are the caveats of every solution.
Don’t forget: debounce
or throttle
are just functions that have an internal time tracker. Call them only once, when the component is mounted. Use such techniques as memoization or creating a ref
if your component with debounced callback is subject to constant re-renders. Take advantage of javascript closures and React ref
if you want to have access to the latest state or props in your debounced function, rather than passing all the data via arguments.
May the force never bounce away from you✌🏼