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
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.
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.
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>
);
};
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.
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.
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.
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.
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!");
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.
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.
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;
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With