Use Debounce hook in React
For the past few months, I’ve been working on a project that had a lot of user interactions (drag and drop, custom scrollbar, mouse move events, etc.). As a result, I often had to use throttling and debouncing for performance.
I found out that, although there are some good solutions for this problem, they weren’t quite what I was looking for. Some of them had a big bundle size (for a simple hook), some of them were using useEffect
to track the updating of the value (which I found interesting, but not good from a performance perspective).
And to my surprise, none of them gave the ability to pass your own debounce
/throttle
function.
So I decided to create my own implementation and share it with you. Let’s get started.
type AnyFunction = (...args: any[]) => any;
export function useDebounce<T extends AnyFunction>(cb: T, wait: number) {
/* ... */
}
Our hook will take 2 arguments. The first one is a function that we’ll denounce. Second, the number of milliseconds to wait.
Now, let’s implement it first in a naive way:
import { useMemo } from 'react';
type AnyFunction = (...args: any[]) => any;
export function useDebounce<T extends AnyFunction>(cb: T, wait: number) {
return useMemo(() => debounce(cb, wait), [cb, wait]);
}
It would work for most cases, but we have 3 problems:
- Callback memoization
- Updates made inside of the
useDebounce
won’t be batched - We can not provide a custom
throttle
/debounce
function
Let’s solve each of these problems step by step.
Callback memoization
Because functional components get recalled on each render, the callback function will be recreated and have a new referential value.
As a result, useMemo
won’t be able to understand that it’s the same function and will be recalculated. This means that debounced function will also be new on each render, which could lead to unnecessary updates/effects.
So what we need to do is create our function once but inside reference the latest value. For this, we will create a useLatest
hook:
import { useRef, useLayoutEffect } from 'react';
function useLatest<T>(value: T) {
const valueRef = useRef(value);
useLayoutEffect(() => {
valueRef.current = value;
});
return valueRef;
}
It instantiates ref with passed value and updates inside useLayoutEffect
on each render. Note that we use layout effect here so our hook will be compatible with React’s concurrent mode when it comes out. It also won’t affect our code, since useLayoutEffect
is called synchronously after render and there will be no time for our debounced callback to be called with the old value.
Now let’s update useDebounce
hook:
export function useDebounce<T extends AnyFunction>(cb: T, wait: number) {
const latestCb = useLatest(cb);
return useMemo(
() =>
debounce((...args: Parameters<T>) => {
latestCb.current(...args);
}, wait),
[wait]
);
}
We still do the same thing as before, but the trick is that refs
have the same reference between rerenders, so the useMemo
won’t get recalculated. But inside of it we would access the latest version of callback (that’s why this hook is called useLatest
).
Batching inside of the hook
We fixed the first problem, but this one is a bit more tricky, and people often forget about it.
As you all know, React’s useState
hook is async, and it doesn’t immediately update the state. For instance:
useEffect(() => {
// this code will result in only 1 update
setA(newA);
setB(newB);
}, []);
This code will update rerender you component only 1 time. But the problem is that it’s not always the case. It happens only inside of the code that was called by React (effects, event handlers, etc.). But if we have some async code that runs outside of React, batching will not work and we could end up with unnecessary updates:
useEffect(() => {
someAsyncFunction().then(({ newA, newB }) => {
// this code will result in 2 updates
setA(newA);
setB(newB);
});
});
To prevent that we could leverage unstable_batchedUpdates
from ReactDOM
.
/* ...other imports... */
import { unstable_batchedUpdates } from 'react-dom';
export function useDebounce<T extends AnyFunction>(cb: T, wait: number) {
const latestCb = useLatest(cb);
return useMemo(
() =>
debounce((...args: Parameters<T>) => {
unstable_batchedUpdates(() => latestCb.current(...args));
}, wait),
[wait]
);
}
This will tell React that we are inside of a batching context and we don’t want immediate updates.
As a result, multiple state updates inside useDebounce
will be batched to a single update.
Passing custom debounce implementation
It’d be also good to add the ability to provide custom debounce implementation. Because you may want not only to use this hook for debouncing but also for throttling. So let’s create a high order function that will create a hook:
/* ... */
type AnyFunction = (...args: any[]) => any;
type Tail<T extends readonly unknown[]> = T extends [unknown, ...infer U]
? U
: never;
export function createDebounceHook<DebounceFn extends AnyFunction>(
debounce: DebounceFn
) {
return function <Callback extends AnyFunction>(
cb: Callback,
...rest: Tail<Parameters<DebounceFn>>
) {
const latestCb = useLatest(cb);
return useMemo(
() =>
debounce((...args: Paramters<Callback>) => {
unstable_batchedUpdates(() => {
latestCb.current(...args);
});
}),
[latestCb, ...rest]
);
};
}
Everything stays the same except that debounce implementation comes from closure. To know what additional parameters does it take, we create a generic DebounceFn
type that extends AnyFunction
.
Now everything should work great, but it doesn’t. Because if we use it like this:
import { debounce } from 'lodash-es';
const useDebounce = createDebounceHook(debounce);
The return type of the useDebounce
would be any
. It happens because debounce
doesn’t have a pre-defined type, it usually takes a generic to determine the parameters of the provided callback. Take a look at this type from lodash-es
:
function debounce<T extends (...args: any[]) => any>(
func: T,
wait?: number,
options?: DebounceSettings
): DebouncedFunc<T>;
When we pass it to createDebounceHook
, TypeScript couldn’t guess what function you’ll provide to it, so it resolves the generic type to (...args: any[]) => any
since it’s the type that generic value should extend. As a result, we end up with a return type any
for our hook.
To fix this we have to write the return type ourselves:
type AnyFunction = (...args: any[]) => any;
type Tail<T extends readonly unknown[]> = T extends [unknown, ...infer U]
? U
: never;
type DebounceFunction<T extends AnyFunction> = {
(...args: Parameters<T>): void;
};
export function createDebounceHook<DebounceFn extends AnyFunction>(
debounce: DebounceFn
) {
return function <Callback extends AnyFunction>(
cb: Callback,
...rest: Tail<Parameters<DebounceFn>>
): DebounceFunction<Callback> {
const latestCb = useLatest(cb);
return useMemo(
() =>
debounce((...args: Paramters<Callback>) => {
unstable_batchedUpdates(() => {
latestCb.current(...args);
});
}),
[latestCb, ...rest]
);
};
}
It works, but we still have one problem. The debounce
function that we pass to createDebounceHook
may bind methods/properties to a function. For instance, debounce
from lodash
adds flush
and cancel
methods. But right now if we use them, we will get a TypeScript error.
import { debounce } from 'lodash-es';
const useDebounce = createDebouncedHook(debounce);
const Component = () => {
const debouncedFn = useDebounce(() => {
/* ... */
}, 100);
debouncedFn.cancel(); // Property 'cancel' does not exist on type DebounceFunction<...>
};
To fix this we have to copy static methods to our DebounceFunction
type.
/* ... */
type GetStaticMethods<T extends AnyFunction> = {
[K in keyof T]: T[K];
};
type DebounceFunction<T extends AnyFunction, U extends AnyFunction> = {
(...args: Parameters<T>): void;
} & GetStaticMethods<U>;
export function createDebounceHook<DebounceFn extends AnyFunction>(
debounce: DebounceFn
) {
return function <Callback extends AnyFunction>(
cb: Callback,
...rest: Tail<Parameters<DebounceFn>>
): DebounceFunction<Callback, DebounceFn> {
const latestCb = useLatest(cb);
return useMemo(
() =>
debounce((...args: Paramters<Callback>) => {
unstable_batchedUpdates(() => {
latestCb.current(...args);
});
}),
[latestCb, ...rest]
);
};
}
We created a GetStaticMethods
type that gets all static properties and methods from function and intersects them with our DebounceFunction
type.
This intersection will give us the correct type for the function and properties bound to it.
Wrapping up
That’s it for today. I hope that you learned something from this article. I also published an npm package based on this article called tiny-use-debounce
:
https://www.npmjs.com/package/tiny-use-debounce
And here is a source code (leave a ⭐️ if it helped):
https://github.com/Ayub-Begimkulov/tiny-use-debounce
Pease!