October 17, 2019
Have you ever heard the story of the QWERTY layout on a keyboard? The popular legend is that it was too easy to type if the letters were arranged alphabetically, and this caused mechanical typewriters to jam. The most common letters were too close together, supposedly. So to fix this, the QWERTY layout was invented, to slow down the typist.
This Dilbertian engineering idea is eerily similar to what a debounce function does.
A debounce function is meant to slow down something in your application, typically a function call. The best way to wrap your head around this is by example.
Consider this: you have a search input on your site somewhere, and as the user types, you want to go fetch some search results to try and match what the user is looking for before they finish typing.
Piece of cake!, you think. With React, you can attach your API call to your input’s onChange
event like so:
Live, editable JSX Snippet:
function SearchForm() {const [inputVal, setInputVal] = React.useState("")const [callCount, setCallCount] = React.useState(0)function handleChange(e) {setInputVal(e.target.value)// let's say this was an API call// to add auto-complete datasetCallCount(callCount + 1)}return (<div><h2>Type in this Box ⬇️</h2><input onChange={handleChange} value={inputVal}/><p>Current Data: {inputVal}</p><p>Calls Done: {callCount}</p></div>)}
Notice that as you type in the search box, if your API function is attached to your input’s onChange
event, you’ll make an API call every time the user presses a key 😱. If you couple this with the small delay it takes to make an API call, you can imagine the traffic jam that this would cause as you have multiple API calls being made and flooding back in.
This isn’t what we imagined when we first cooked up this auto-populating search box scheme. What we really want to do is to make our API call when the user pauses or stops typing.
This is the purpose of a debounce function, to limit the amount of calls that can happen in a given amount of time.
So we need to fire fewer API calls, but how do we do it?
Before we jump into React, let’s give this a shot with regular JavaScript. Let’s put our fake API call in its own function, then wrap it in our debounce function.
Live, editable JavaScript Snippet:
Success!
Without a debounce, we get 3 calls, but with a debounce, we only fire an API call on the last function call.
The most basic, critical piece of this debounce function is to delay the actual API call, then as more calls come in, cancel and reset the delay for the API call. We do this with setTimeout
and clearTimeout
in the JavaScript above.
If you noticed the debounce function taking a function and returning a another function, that is an example of a closure in JavaScript. When we debounce a function, we pass our original function in, and wrap it in another function that delays calls to the original. In this way our debounce function is reusable throughout our program. We could debounce as many different functions as we want, because each one has its own timeoutId
variable.
React allows us to encapsulate logic in components, so we can skip the fancy JavaScript closures and just use our component to write a debounce function.
Let’s take a look:
Live, editable JSX Snippet:
const { useState, useRef, useEffect } = React// just an async helperfunction fakeAPICall() {return new Promise(resolve => {setTimeout(resolve, 300)})}function SearchForm() {const [inputVal, setInputVal] = useState("")const [query, setQuery] = useState("")const inputRef = useRef("")const [callCount, setCallCount] = useState(0)const timeoutId = useRef()function handleChange(e) {setInputVal(e.target.value)// mimic the value so we can access// the latest value in our API callinputRef.current = e.target.value}useEffect(() => {// if the user keeps typing, stop the API call!clearTimeout(timeoutId.current)// don't make an API call with no dataif (!inputVal.trim()) return// capture the timeoutId so we can// stop the call if the user keeps typingtimeoutId.current = setTimeout(() => {// grab our query, but store it in state so// I can show it to you below in the example 😄setQuery(inputRef.current)fakeAPICall()// here we pass a callback so we get the current callCount value// from the useState hook's setter function// we use a Ref for timeoutId to avoid this same problem.then(() => setCallCount(callCount => callCount + 1))}, 800)}, [inputVal])return (<div><h2>Type in this Box ⬇️</h2><input onChange={handleChange} value={inputVal}/><p>Current Data: {inputVal}</p><p>Query Sent: {query}</p><p>Calls Done: {callCount}</p></div>)}render(SearchForm)
Now as we type, the component won’t actually make any API calls until the typing stops.
The only difference here is that instead of writing a closure, we’re using a React Ref for our timeoutId
. Refs are React’s version of instance variables, so each SearchForm component that we make should get its own timeoutId
. If you want to learn more about Refs and useEffect
, I wrote another post on that topic.
This might not be exactly what you imagined when you envisioned this functionality. For example, as you type into Google search, you still get autocomplete suggestions as you type, even if you haven’t stopped typing.
So while our previous examples will ensure we do the fewest API calls possible, we may want to tweak our solution to make an API call every so often as the user types. This would be a throttle function.
Let’s tweak our JavaScript debounce implementation so that we only make our API call every 800ms.
Live, editable JavaScript Snippet:
Now as our throttle function fires, we are limiting our calls to happen every 800ms.
This new version uses a simple true
/false
value to determine if we should trigger more calls instead of clearing the timeout and cancelling previous calls. Now the first call to the throttled function tees up the call, and the subsequent calls are ignored until the API call is complete.
Let’s apply this same functionality to our previous React example.
Live, editable JSX Snippet:
const {useState, useRef, useEffect} = React// just an async helperfunction fakeAPICall() {return new Promise(resolve => {setTimeout(resolve, 300)})}function SearchForm() {const [inputVal, setInputVal] = useState("")const [query, setQuery] = useState("")const inputRef = useRef("")const [callCount, setCallCount] = useState(0)const makingCall = useRef(false)function handleChange(e) {setInputVal(e.target.value)// mimic the value so we can access// the latest value in our API callinputRef.current = e.target.value}useEffect(() => {// if there's no value or we've already triggered a call// prevent further callsif (!inputVal.trim() || makingCall.current) returnmakingCall.current = truesetTimeout(() => {makingCall.current = false// again, this setQuery is just so I can// render the query below.// if this API call were real, we'd probably// pass the query into the API call functionsetQuery(inputRef.current)fakeAPICall().then(() => {setCallCount(callCount => callCount + 1)})}, 800)}, [inputVal])return (<div><h2>Type in this Box ⬇️</h2><input onChange={handleChange} value={inputVal}/><p>Current Data: {inputVal}</p><p>Query Sent: {query}</p><p>Calls Done: {callCount}</p></div>)}render(SearchForm)
Success! Now as the user types, every 800ms we make a call for an autocomplete suggestion. This means more API calls, but better user experience, at least in the case of our search autocomplete example.
So there you have it: throttling and debounce functions in JS and React.
But would you ever implement this yourself in real life?
Sure! If you just needed simple functionality like this, you could absolutely manage your own debounce logic/helpers in your app. However, there’s no shame in pulling in Lodash and using the debounce or throttle functions that they’ve implemented.
I find it fun to try and implement my own solutions, and I think it’s worth the mental gymnastics to give this stuff a shot in your own code every once in a while. But don’t be fooled, nobody will judge you if you reach for a third-party solution!
Written by Lee Warrick, Co-host of the Tech Jr Podcast, Software Engineer, Guitarist, and Gamer. Subscribe to my newsletter! Show your support by buying some SWAG.