7

I have a here a input field that on every type, it dispatches a redux action. I have put a useDebounce in order that it won't be very heavy. The problem is that it says Hooks can only be called inside of the body of a function component. What is the proper way to do it?

useTimeout

import { useCallback, useEffect, useRef } from "react";

export default function useTimeout(callback, delay) {
  const callbackRef = useRef(callback);
  const timeoutRef = useRef();

  useEffect(() => {
    callbackRef.current = callback;
  }, [callback]);

  const set = useCallback(() => {
    timeoutRef.current = setTimeout(() => callbackRef.current(), delay);
  }, [delay]);

  const clear = useCallback(() => {
    timeoutRef.current && clearTimeout(timeoutRef.current);
  }, []);

  useEffect(() => {
    set();
    return clear;
  }, [delay, set, clear]);

  const reset = useCallback(() => {
    clear();
    set();
  }, [clear, set]);

  return { reset, clear };
}

useDebounce

import { useEffect } from "react";
import useTimeout from "./useTimeout";

export default function useDebounce(callback, delay, dependencies) {
  const { reset, clear } = useTimeout(callback, delay);
  useEffect(reset, [...dependencies, reset]);
  useEffect(clear, []);
}

Form component

import React from "react";
import TextField from "@mui/material/TextField";
import useDebounce from "../hooks/useDebounce";

export default function ProductInputs(props) {
  const { handleChangeProductName = () => {} } = props;

  return (
    <TextField
      fullWidth
      label="Name"
      variant="outlined"
      size="small"
      name="productName"
      value={formik.values.productName}
      helperText={formik.touched.productName ? formik.errors.productName : ""}
      error={formik.touched.productName && Boolean(formik.errors.productName)}
      onChange={(e) => {
        formik.setFieldValue("productName", e.target.value);
        useDebounce(() => handleChangeProductName(e.target.value), 1000, [
          e.target.value,
        ]);
      }}
    />
  );
}
Joseph
  • 7,042
  • 23
  • 83
  • 181
  • yea that's absolutely the wrong place to put a hook. Hooks should be placed outside of the rendered elements. Move it inside the body of the parent component of `TextField` – smac89 Dec 08 '21 at 03:11
  • Your hook is called from a function inside your components, this breaks the rule of [hooks](https://reactjs.org/docs/hooks-rules.html) You should use the hook at the top level. – vaibhavmande Dec 08 '21 at 03:11
  • @smac89. so how would you move it and call from that? – Joseph Dec 08 '21 at 03:20
  • Did you define that `useDebounce` yourself? How did you intend to use it? – Bergi Dec 08 '21 at 03:27
  • @Bergi. Updated my question. I want to dispatch an action to redux using `handleChangeProductName` not on every input cause i have a lot of textfields so it would be heavy – Joseph Dec 08 '21 at 03:32
  • I agree, debouncing doesn't seem a good fit for a React hook. From what I can tell, you just want to debounce the `handleChangeProductName` function call. If this is correct then do either of this [answer](https://stackoverflow.com/a/68427477/8690857) or this [answer](https://stackoverflow.com/a/66394949/8690857) answer/resolve your question/issue? – Drew Reese Dec 08 '21 at 05:10

3 Answers3

6

I don't think React hooks are a good fit for a throttle or debounce function. From what I understand of your question you effectively want to debounce the handleChangeProductName function.

Here's a simple higher order function you can use to decorate a callback function with to debounce it. If the returned function is invoked again before the timeout expires then the timeout is cleared and reinstantiated. Only when the timeout expires is the decorated function then invoked and passed the arguments.

const debounce = (fn, delay) => {
  let timerId;
  return (...args) => {
    clearTimeout(timerId);
    timerId = setTimeout(() => fn(...args), delay);
  }
};

Example usage:

export default function ProductInputs({ handleChangeProductName }) {
  const debouncedHandler = useCallback(
    debounce(handleChangeProductName, 200),
    [handleChangeProductName]
  );

  return (
    <TextField
      fullWidth
      label="Name"
      variant="outlined"
      size="small"
      name="productName"
      value={formik.values.productName}
      helperText={formik.touched.productName ? formik.errors.productName : ""}
      error={formik.touched.productName && Boolean(formik.errors.productName)}
      onChange={(e) => {
        formik.setFieldValue("productName", e.target.value);
        debouncedHandler(e.target.value);
      }}
    />
  );
}

If possible the parent component passing the handleChangeProductName callback as a prop should probably handle creating a debounced, memoized handler, but the above should work as well.

Drew Reese
  • 165,259
  • 14
  • 153
  • 181
  • 1
    @Joseph If the user is actively typing you may as well wait for them to finish so you're not making wasted intermediate asynchronous calls on input that's changed. – Drew Reese Dec 08 '21 at 06:36
  • How can you do that with this? Can you also edit your answer with that? Thanks Drew – Joseph Dec 08 '21 at 06:38
  • 1
    @Joseph You tune the debouncing delay to something a bit longer and "guess" when the user is done typing. Or in other words, the delay is the time needed after the user stops typing to invoke the function. – Drew Reese Dec 08 '21 at 06:41
  • 1
    Because `handleChangeProductName` is coming from props, it needs to be in the dependency array argument provided to the `useCallback` hook. – jsejcksn Jul 26 '22 at 01:05
1

Debouncing onChange itself has caveats. Say, it must be uncontrolled component, since debouncing onChange on controlled component would cause annoying lags on typing.

Another pitfall, we might need to do something immediately and to do something else after a delay. Say, immediately display loading indicator instead of (obsolete) search results after any change, but send actual request only after user stops typing.

With all this in mind, instead of debouncing callback I propose to debounce sync-up through useEffect:

const [text, setText] = useState('');
const isValueSettled = useIsSettled(text);

useEffect(() => {
  if (isValueSettled) {
    props.onChange(text);
  }
}, [text, isValueSettled]);

...
  <input value={value} onChange={({ target: { value } }) => setText(value)}

And useIsSetlled itself will debounce:

function useIsSettled(value, delay = 500) {
  const [isSettled, setIsSettled] = useState(true);
  const isFirstRun = useRef(true);
  const prevValueRef = useRef(value);

  useEffect(() => {
    if (isFirstRun.current) {
      isFirstRun.current = false;
      return;
    }
    setIsSettled(false);
    prevValueRef.current = value;
    const timerId = setTimeout(() => {
      setIsSettled(true);
    }, delay);
    return () => { clearTimeout(timerId); }
  }, [delay, value]);
  if (isFirstRun.current) {
    return true;
  }
  return isSettled && prevValueRef.current === value;
}

where isFirstRun is obviously save us from getting "oh, no, user changed something" after initial rendering(when value is changed from undefined to initial value).

And prevValueRef.current === value is not required part but makes us sure we will get useIsSettled returning false in the same render run, not in next, only after useEffect executed.

skyboyer
  • 22,209
  • 7
  • 57
  • 64
0

Taking a look at your implementation of useDebounce, and it doesn't look very useful as a hook. It seems to have taken over the job of calling your function, and doesn't return anything, but most of it's implementation is being done in useTimeout, which also not doing much...

In my opinion, useDebounce should return a "debounced" version of callback

Here is my take on useDebounce:

export default function useDebounce(callback, delay) {
  const [debounceReady, setDebounceReady] = useState(true);

  const debouncedCallback = useCallback((...args) => {
    if (debounceReady) {
      callback(...args);
      setDebounceReady(false);
    }
  }, [debounceReady, callback]);

  useEffect(() => {
    if (debounceReady) {
      return undefined;
    }
    const interval = setTimeout(() => setDebounceReady(true), delay);
    return () => clearTimeout(interval);    
  }, [debounceReady, delay]);

  return debouncedCallback;
}

Usage will look something like:

import React from "react";
import TextField from "@mui/material/TextField";
import useDebounce from "../hooks/useDebounce";

export default function ProductInputs(props) {
  const handleChangeProductName = useCallback((value) => {
    if (props.handleChangeProductName) {
      props.handleChangeProductName(value);
    } else {
      // do something else...
    };
  }, [props.handleChangeProductName]);

  const debouncedHandleChangeProductName = useDebounce(handleChangeProductName, 1000);

  return (
    <TextField
      fullWidth
      label="Name"
      variant="outlined"
      size="small"
      name="productName"
      value={formik.values.productName}
      helperText={formik.touched.productName ? formik.errors.productName : ""}
      error={formik.touched.productName && Boolean(formik.errors.productName)}
      onChange={(e) => {
        formik.setFieldValue("productName", e.target.value);
        debouncedHandleChangeProductName(e.target.value);
      }}
    />
  );
}
smac89
  • 39,374
  • 15
  • 132
  • 179
  • Seems it just passing the first letter on the `props.handleChangeProductName` – Joseph Dec 08 '21 at 04:10
  • @Joseph try reducing the debounce time. Frankly 1 second is a bit too long to wait. Try something smaller like 150. Something else to consider is that debounce only takes the latest value after the timeout, so you may need to update the `useDebounce` hook to keep track of the last arguments passed to debounce and call the function after a timeout is over – smac89 Dec 08 '21 at 05:15
  • can you edit your answer with that? – Joseph Dec 08 '21 at 05:23