Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Function called in setTimeout not using current React state

quick summary

I'm trying to create a button that has both a regular click and a separate action that happens when a user clicks and holds it, similar to the back button in Chrome.

The way I'm doing this involves a setTimeout() with a callback that checks for something in state. For some reason, the callback is using state from the time that setTimeout() was called, and not at the time when it's callback is called (1 second later).

You can view it on codesandbox

how I'm trying to accomplish this

In order to get this feature, I'm calling setTimeOut() onMouseDown. I also set isHolding, which is in state, to true.

onMouseUp I set isHolding to false and also run clickHandler(), which is a prop, if the hold function hasn't had time to be called.

The callback in setTimeOut() will check if isHolding is true, and if it is, it will run clickHoldHandler(), which is a prop.

problem

isHolding is in state (I'm using hooks), but when setTimeout() fires it's callback, I'm not getting back the current state, but what the state was when setTimetout() was first called.

my code

Here's how I'm doing it:

const Button = ({ clickHandler, clickHoldHandler, children }) => {
  const [isHolding, setIsHolding] = useState(false);
  const [holdStartTime, setHoldStartTime] = useState(undefined);
  const holdTime = 1000;

  const clickHoldAction = e => {
    console.log(`is holding: ${isHolding}`);
    if (isHolding) {
      clickHoldHandler(e);
    }
  };

  const onMouseDown = e => {
    setIsHolding(true);
    setHoldStartTime(new Date().getTime());

    setTimeout(() => {
      clickHoldAction(e);
    }, holdTime);
  };

  const onMouseUp = e => {
    setIsHolding(false);

    const totalHoldTime = new Date().getTime() - holdStartTime;
    if (totalHoldTime < holdTime || !clickHoldHandler) {
      clickHandler(e);
    }
  };

  const cancelHold = () => {
    setIsHolding(false);
  };

  return (
    <button
      onMouseDown={onMouseDown}
      onMouseUp={onMouseUp}
      onMouseLeave={cancelHold}
    >
      {children}
    </button>
  );
};
like image 390
Zach Avatar asked Apr 23 '19 19:04

Zach


People also ask

Why setTimeout function does not have latest state value?

The function given to setTimeout will get the flag variable from the initial render, since flag is not mutated. You could instead give a function as argument to toggleFlag . This function will get the correct flag value as argument, and what is returned from this function is what will replace the state.

How do you delay a call in React?

Define a function that takes the number of milliseconds as parameter. Use the setTimeout method to resolve a Promise after the provided number of milliseconds.

How do I use clearTimeout in useEffect?

To clear a timeout or an interval in React with hooks: Use the useEffect hook to set up the timeout or interval. Return a function from the useEffect hook. Use the clearTimeout() or clearInterval() methods to remove the timeout when the component unmounts.

What is setTimeout () in react?

React - The Complete Guide (incl Hooks, React Router, Redux) What is a setTimeout function? The setTimeout () function is used to invoke a function or a piece of code after a specified amount of time is completed.

How to update state after setState () in react?

Reading state right after calling setState () a potential pitfall. Returns a stateful value, and a function to update it. The function to update the state can be called with a new value or with an updater function argument. const [value, setValue] = useState (""); setValue ("React is awesome!");

Is it possible to call setState in setTimeout?

Calling setState in setTimeout is therefore sync. I can confirm this behavior from my experience. This is not a bug, but it will probably change in the future (this was also mentioned in the issue I base my answer on). I am not part of the React team, correct me if I wrote something stupid.

How to run a setTimeout function when a component state is updated?

If you want to run a setTimeout function whenever a component state is updated, you need to pass the condition to an empty array []. Here we passed count as a condition to the array so that setTimeout function runs initially and also when a count value is changed.


1 Answers

You should wrap that callback task into a reducer and trigger the timeout as an effect. Yes, that makes things certainly more complicated (but it's "best practice"):

  const Button = ({ clickHandler, clickHoldHandler, children }) => {
     const holdTime = 1000;
     const [holding, pointer] = useReducer((state, action) => {
        if(action === "down") 
           return { holding: true, time: Date.now()  };
        if(action === "up") {
          if(!state.holding)
              return { holding: false };
          if(state.time + holdTime > Date.now()) {
                clickHandler();
          } else {
                clickHoldHandler();
          }
          return { holding: false };
        }
        if(action === "leave")
          return { holding: false };
     }, { holding: false, time: 0 });

     useEffect(() => {
       if(holding.holding) {
         const timer = setTimeout(() => pointer("up"), holdTime - Date.now() + holding.time);
         return () => clearTimeout(timer);
       }
     }, [holding]);

     return (
       <button
         onMouseDown={() => pointer("down")}
         onMouseUp={() => pointer("up")}
         onMouseLeave={() => pointer("leave")}
       >
         {children}
        </button>
    );
  };

working sandbox: https://codesandbox.io/s/7yn9xmx15j


As a fallback if the reducer gets too complicated, you could memoize an object of settings (not best practice):

 const state = useMemo({
   isHolding: false,
   holdStartTime: undefined,
 }, []);

 // somewhere
 state.isHolding = true;
like image 103
Jonas Wilms Avatar answered Nov 11 '22 13:11

Jonas Wilms