Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Can't get `lodash.debounce()` to work properly? Executed multiple times... (react, lodash, hooks)

I am trying to update the state of a child component in React as the range input moves. And, I want to fire the update function to the parent component's state with Lodash's debounce function so that I don't set the state of the parent component every time range input fires an event.

However, after debounce's delay, all the events are getting fired. As if I called setTimeout functions consecutively on every range input event, but not debounce.

I can't find what am I missing here. How can I have the function passed into "debounce" get executed once after a series of range input events?

My simplified code looks like this:

import _ from 'lodash'
import React from 'react'

const Form: React.FC<Props> = props => {
    const [selectedStorageSize, setSelectedStorageSize] = React.useState(props.storageSize)

    const handleChangeAt = (field, payload) => {
      props.handleFormChangeAt(FormField.InstanceDefs, {
        ...form[FormField.InstanceDefs],
        [field]: payload,
      })
    }

    const debouncedChange = _.debounce(
     (field, payload) => handleChangeAt(field, payload),
     500,
   )

   return(
        <input
          required
          type="range"
          label="Storage Size/GB"
          min={50}
          max={500}
          value={props.selectedStorageSize}
          step={5}
          onChange={e => {
            setSelectedStorageSize(Number(e.target.value))
            debouncedChange(FormField.StorageSize, Number(e.target.value))
          }}
        />
  }
like image 398
dugong Avatar asked Dec 04 '19 19:12

dugong


People also ask

What is leading and trailing in debounce?

Similarly to debounce , Lodash implementation of throttle supports a leading and a trailing parameters, allowing to set whether we want to execute the function at the beginning of the time period ( leading ), at the end of it ( trailing ) or both. Stream of events as they happen and as they trigger throttled handlers.

Does debounce return a promise?

Creates a debounced function that returns a promise, but delays invoking the provided function until at least ms milliseconds have elapsed since the last time it was invoked. All promises returned during this time will return the same data.

How do you add debounce in react?

Here's a simple implementation : import React, { useCallback } from "react"; import { debounce } from "lodash"; const handler = useCallback(debounce(someFunction, 2000), []); const onChange = (event) => { // perform any event related action here handler(); };


2 Answers

The Problem

_.debounce creates a function that debounces successive calls to the same function instance. But this is creating a new one every render, so successive calls aren't calling the same instance.

You need to reuse the same function across renders. There's a straightforward way to do that with hooks... and a better way to do it:

The straightforward (flawed) solution - useCallback

The most straightforward way of preserving things across render is useMemo/useCallback. (useCallback is really just a special case of useMemo for functions)

const debouncedCallback = useCallback(_.debounce(
    (field, payload) => handleChangeAt(field, payload),
    500,
), [handleChangeAt])

We've got an issue with handleChangeAt: it depends on props and creates a different instance every render, in order to capture the latest version of props. If we only created debouncedCallback once, we'd capture the first instance of handleChangeAt, and capture the initial value of props, giving us stale data later.

We fix that by adding [handleChangeAt], to recreate the debouncedCallback whenever handleChangeAt changes. However, as written, handleChangeAt changes every render. So this change alone won't change the initial behavior: we'd still recreate debouncedCallback every render. So you'd need to memoize handleChangeAt, too:

const { handleFormChangeAt } = props;
const handleChangeAt = useCallback((field, payload) => {
    handleFormChangeAt(/*...*/)
}, [handleFormChangeAt]);

(If this sort of memoizing isn't familiar to you, I highly recommend Dan Abramov's Complete Guide to useEffect, even though we aren't actually using useEffect here)

This pushes the problem up the tree, and you'll need to make sure that whatever component provides props.handleFormChangeAt is also memoizing it. But, otherwise this solution largely works...

The better solution - useRef

Two issues with the previous solution: as mentioned it pushes the problem of memoization up the tree (specifically because you're depending on a function passed as a prop), but the whole point of this is so that we can recreate the function whenever we need to, to avoid stale data.

But the recreating to avoid stale data is going to cause the function to be recreated, which is going to cause the debounce to reset: so the result of the previous solution is something that usually debounces, but might not, if props or state have changed.

A better solution requires us to really only create the memoized function once, but to do so in a way that avoids stale data. We can do that by using a ref:

const debouncedFunctionRef = useRef()
debouncedFunctionRef.current = (field, payload) => handleChangeAt(field, payload);

const debouncedChange = useCallback(_.debounce(
    (...args) => debouncedFunctionRef.current(...args),
    500,
), []);

This stores the current instance of the function to be debounced in a ref, and updates it every render (preventing stale data). Instead of debouncing that function directly, though, we debounce a wrapper function that reads the current version from the ref and calls it.

Since the only thing the callback depends on is a ref (which is a constant, mutable object), it's okay for useCallback to take [] as its dependencies, and so we'll only debounce the function once per component, as expected.

As a custom hook

This approach could be moved into its own custom hook:

const useDebouncedCallback = (callback, delay) => {
    const callbackRef = useRef()
    callbackRef.current = callback;
    return useCallback(_.debounce(
        (...args) => callbackRef.current(...args),
        delay,
    ), []);
};

const { useCallback, useState, useRef, useEffect } = React;

const useDebouncedCallback = (callback, delay, opts) => {
  const callbackRef = useRef()
  callbackRef.current = callback;
  return useCallback(_.debounce(
      (...args) => callbackRef.current(...args),
      delay,
      opts
  ), []);
};

function Reporter({count}) {
  const [msg, setMsg] = useState("Click to record the count")
  const onClick = useDebouncedCallback(() => {
    setMsg(`The count was ${count} when you clicked`);
  }, 2000, {leading: true, trailing: false})
  return <div>
    <div><button onClick={onClick}>Click</button></div>
    {msg}
    </div>
}

function Parent() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    setInterval(() => setCount(x => x+1), 500)
  }, [])
  return (
    <div>
      <div>The count is {count}</div>
      <Reporter count={count} />
    </div>
  );
}

const rootElement = document.getElementById("root");
ReactDOM.render(<Parent />, rootElement);
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.15/lodash.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.10.2/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.10.2/umd/react-dom.production.min.js"></script>
<div id="root" />
like image 74
Retsam Avatar answered Oct 16 '22 19:10

Retsam


I used useCallback with _.debounce, but faced with a eslint error, 'React Hook useCallback received a function whose dependencies are unknown. Pass an inline function instead.'

Finally, I found this issue, and used useMemo.

before:

 const debouncedMethod = useCallback(
    debounce((arg) => {
      someMethod(arg);
    }, 500),
    [someMethod],
  );

after:

  const debouncedMethod = useMemo(
    () =>
      debounce((arg) => {
        someMethod(arg);
      }, 500),
    [someMethod],
  );
like image 26
mozu Avatar answered Oct 16 '22 19:10

mozu