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.

How to debounce and throttle in React without losing your mind

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.

See example in code sandbox

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✌🏼