Creating useDebounce hook

March 28, 2021

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!


© 2023 Ayub Begimkulov All Rights Reserved