Originally published at https://www.developerway.com. The website has more articles like this đ
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 timer
let 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 passed
if (shouldCallCallback(Date.now())) {
callback();
} else {
// if time hasn't passed yet, restart the timer
timer = 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 value
const [value, setValue] = useState();
const onChange = (e) => {};
const debouncedOnChange = debounce(onChange, 500);
// turning input into controlled component by passing value from state there
return <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 event
setValue(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 debounced
setValue(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 debounced
const debouncedSendRequest = debounce(sendRequest, 500);
// onChange is not debounced anymore, it just calls debounced function
const onChange = (e) => {
const value = e.target.value;
// state is updated on every value change, so input will work
setValue(value);
// call debounced request here
debouncedSendRequest(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 anymore
debouncedSendRequest(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 below
const sendRequest = useCallback((value: string) => {
console.log("Changed value:", value);
}, []);
// memoize the debounce call with useMemo
const 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 directly
debouncedSendRequest(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 state
console.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 state
const 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 call
const ref = useRef(debounce(() => {
// this is our old "debouncedSendRequest" function
}, 500));
const onChange = (e) => {
const value = e.target.value;
// calling the debounced function
ref.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 state
console.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 call
const ref = useRef(debounce(() => {
// send request to the backend here
}, 500));
useEffect(() => {
// updating ref when state changes
ref.current = debounce(() => {
// send request to the backend here
}, 500);
}, [value]);
const onChange = (e) => {
const value = e.target.value;
// calling the debounced function
ref.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 state
console.log(value);
};
// creating ref and initializing it with the sendRequest function
const ref = useRef(sendRequest);
useEffect(() => {
// updating ref when state changes
// now, ref.current will have the latest sendRequest with access to the latest state
ref.current = sendRequest;
}, [value]);
// creating debounced callback only once - on mount
const debouncedCallback = useMemo(() => {
// func will be created only once - on mount
const func = () => {
// ref is mutable! ref.current is a reference to the latest sendRequest
ref.current?.();
};
// debounce the func that was created once, but has access to the latest sendRequest
return debounce(func, 1000);
// no dependencies! never gets updated
}, []);
const onChange = (e) => {
const value = e.target.value;
// calling the debounced function
debouncedCallback();
};
}
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 here
console.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âđź
Originally published at https://www.developerway.com. The website has more articles like this đ
Subscribe to the newsletter, connect on LinkedIn or follow on Twitter to get notified as soon as the next article comes out.