javascriptreactjsreact-hookssettimeoutsetinterval

React: settimeout's callback doesn't have updated state value


I want to call a function at regular intervals and update state inside it.

I started with a useEffect but eventually ended up here. I tried using Ref.current to store the interval and clear it every time in the function and recreate it but it still wasn't working. Something like this

const drawBall = () => {
  clearInterval(intervalRef.current);
  // Access state and update it
  intervalRef.current = setInterval(drawBall, 1000);
}

I dropped the idea and thought of using a simple setTimeout. Everytime drawBall is called, it calls a new Timeout, even if the closure takes the value at the time of being called, it still will have the updated value as the value at the time of calling is updated. But it is not working as expected, the function still gets the old value even though the value is being updated correctly. The code goes like this -

const drawBall = () => {
 console.log(ballPos); // Shows initial value in every call (not the expected behaviour)
 setBallPos(prevVal => {
   console.log(prevVal); // Shows the right value
   return {
       x: prevVal.x + 2,
       y: prevVal.y + 2
   }
 });

 setTimeout(drawBall, 1000);
}

What am I missing here?

Edit: Switching to use-effect doesn't help. It updates ballPos correctly, but that was happening before too. The issue still remains that when I access ballPos.x even inside the same useEffect, it shows the original value, not the updated one.

This is the new useEffect code

 useEffect(() => {
        const interval = setInterval(() => {

            const canvas = gameBoard.current;
            const ctx = canvas.getContext('2d');
            let currX = ballPos.x;
            let currY = ballPos.y;
            currX += defaultDX;
            currY += defaultDY;
            console.log(currX, currY); // Shows Old Value
            ctx.fillStyle = "#FF0000";
            ctx.beginPath();
            ctx.arc(currX, currY, 10, 0, Math.PI * 2, true);
            ctx.fill();

            setBallPos((prevVal) => {
                return {
                    x: prevVal.x + defaultDX,
                    y: prevVal.y + defaultDY
                }
            });
        }, 1000);

        return () => {
            clearInterval(interval);
        }
    }, [])

Solution

  • While you're updating the state correctly, you continue to use the old value to draw on the canvas because the ballPos reference inside the interval callback is not aware of state changes.

    You could use the new state value for the draw call as shown below, or move the requestAnimationFrame part to its own Effect entirely.

    useEffect(() => {
        const interval = setInterval(() => {
            setBallPos((prevVal) => {
                const newBallPos = {
                    x: prevVal.x + defaultDX,
                    y: prevVal.y + defaultDY
                };
                requestAnimationFrame(() => {
                    const canvas = gameBoard.current;
                    const ctx = canvas.getContext('2d');
                    console.log(newBallPos);
                    ctx.fillStyle = "#FF0000";
                    ctx.beginPath();
                    ctx.arc(newBallPos.x, newBallPos.y, 10, 0, Math.PI * 2, true);
                    ctx.fill();
                });
                return newBallPos;
            });
        }, 1000);
        return () => clearInterval(interval)
    }, [])