How to Use Lodash Debounce & Lodash Throttle in React

Lodash provides two powerful utility functions, debounce and throttle, that help optimize performance when dealing with high-frequency events such as scrolling, resizing, and typing. In this blog post, we will explore how to use these functions effectively in a React application.

TL;DR

If you're looking for a quick answer, jump straight to the conclusion section at the end of this blog post.

Prerequisites:

You should have a basic understanding of React. Familiarity with hooks like useCallback is recommended but not required. To understand the concepts of debouncing and throttling, refer to this article written by David Corbacho on CSS-Tricks: Debouncing and Throttling Explained.

For a quick overview:

  • Debouncing: Ensures that a function executes only after a specified delay has passed since the last time it was invoked. This is useful for reducing unnecessary computations when handling events like user input.

  • Throttling: Ensures that a function executes at most once in a specified time interval. This is useful for limiting the rate at which an event handler executes, such as handling scroll events.

What's the Catch with Using Lodash Debounce & Lodash Throttle in React?

React components often re-render due to state changes, which can lead to unexpected behavior when using utility functions like debounce and throttle from Lodash. Let's take a look at a common mistake when using debounce in a React component:

import { useState } from 'react';
import debounce from 'lodash.debounce';

const SomeChildComponent = ({}) => {
  const debouncedFunction = debounce(() => {
    console.log('Debounced function called');
  }, 500);

  return (
    <button onClick={debouncedFunction}>Click me</button>
  );
};

Why is This Incorrect?

The issue here is that the debouncedFunction is re-created on every render. This means that the debouncing effect is lost because a new instance of the debounced function is generated each time the component re-renders. As a result, the function won't behave as expected, and the debounce logic will fail.

How to Fix It?

To ensure that the debounced function only has one instance across re-renders and retains its behavior, we need to memoize it using the useCallback hook. This way, the function is only created once and reused on subsequent renders:

import { useCallback } from 'react';
import debounce from 'lodash.debounce';

const SomeChildComponent = (props) => {
  const debouncedFunction = useCallback(
    debounce(() => {
      console.log('Debounced function called');
    }, 500),
    []
  );

  return (
    <button onClick={debouncedFunction}>Click me</button>
  );
};

The above code works as expected, but what if the debounced function depends on some state or other dependencies? Let’s explore a common mistake when using a memoized debounce with useCallback in a React component:

const {count, setCount} = props;

const debouncedIncrement = useCallback(
  debounce(() => {
    setCount(count + 1);
  }, 300),
  [count, setCount]
);

// Usage inside an event handler
<button onClick={() => debouncedIncrement()}>
  Click me
</button>

Why is This Incorrect?

The issue here is that the debouncedIncrement function is re-created every time the count state changes. This happens because count is listed as a dependency in the useCallback hook. As a result, the debounce effect is lost whenever count changes, and the function no longer behaves as intended. Same goes for setCount function dependency of debouncedIncrement function as well.

How to Fix It?

To fix this, we need to ensure that the debouncedIncrement function is pure and doesn’t rely on external dependencies. Instead, we can pass the necessary external dependecies like the count state and setCount setter function as arguments to the debounced function. Here’s how you can do it:

const {count, setCount} = props;

const debouncedIncrement = useCallback(
  debounce(({ count, setCount }) => {
    setCount(count + 1);
  }, 300),
  []
);

// Usage inside an event handler
<button onClick={() => debouncedIncrement({ count, setCount })}>
  Click me
</button>

This approach ensures that the debouncedIncrement function is only created once and doesn’t lose its debouncing effect, even when count or setCount change.

Conclusion

So, to sum up, when using debounce or throttle in React, make sure to memoize the function using useCallback to avoid losing its intended behavior. If the debounced function depends on external state or props, pass them as arguments to the debounced function instead of listing them as dependencies in useCallback. Always make sure that the debounced function is pure and doesn’t rely on external dependencies to maintain its debouncing effect. Instead, pass the external dependencies as arguments to the debounced function

We can create a custom hooks for convenience:

import debounce from 'lodash.debounce'
import throttle from 'lodash.throttle'
import { useCallback } from 'react'

export function useDebouncedCallback(pureFunc, wait, options) {
  return useCallback(
    debounce(pureFunc, wait, options), 
    []
  )
}

export function useThrottledCallback(pureFunc, wait, options) {
  return useCallback(
    throttle(pureFunc, wait, options), 
    []
  )
}

For TypeScript users, you can checkout this GitHub Gist for the hook definitions with proper types. Give it a star if you find it useful!

0
Subscribe to my newsletter

Read articles from Ashutosh Khanduala directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Ashutosh Khanduala
Ashutosh Khanduala

I am a Android/Web developer. I use React Native, Next.js and TypeScript to make cool stuff. Checkout my GitHub: https://github.com/ashuvssut