Update React State variable inside an Interval

Viewed 573

I create this simple example script to animate a player position using a simple setInterval function but impossible to make it works..

Everytime it go into the interval function the playerPos is back to is initial value..

import React, { useEffect, useState } from 'react';

const App = () => {

  let [playerPos, setPlayerPos] = useState({x:128, y:128})

  useEffect(() => {

    setInterval(() => {

      console.log(`Current player pos ${JSON.stringify(playerPos)}`);
      setPlayerPos({x:128, y:playerPos.y + 10});

    }, 1000);

  }, []);

  return <div style={{position: "absolute", top: `${playerPos.x}px`, left: `${playerPos.y}px`}}></div>
}

Strangely, my console only showing:

Current player pos {"x":128,"y":128}
Current player pos {"x":128,"y":128}
Current player pos {"x":128,"y":128}
...

What did I misunderstood in hooks and effects ??!

2 Answers

The useEffect only ran once, when the component initially loaded, and at the time only had the initial value of the playerPos state. So you effectively have a closure around that one value that's just repeating.

Instead of using setInterval, rely on useEffect to run multiple times whenever the state changes by adding that state value to its dependency array. Then just use setTimeout to add the delay each time. For example:

useEffect(() => {
  setTimeout(() => {
    console.log(`Current player pos ${JSON.stringify(playerPos)}`);
    setPlayerPos({x:128, y:playerPos.y + 10});
  }, 1000);
}, [playerPos]);

So each time the component renders, useEffect will re-run if playerPos has changed. And each time it runs, playerPos will be changed one second later, triggering a re-render.

After the first render, you set up the interval, and the code closes over the playerPos that existed at that time. That's an object with {x: 128, y: 128}, and it always will be. The empty dependency array means the effect will never run again, and setting state does not (and can not) replace the local variable playerPos.

Since all you're doing is setting state, you can fix this by using the function version of set state. React will pass you the most recent value of the state, and then you can calculate your new state based on that.

useEffect(() => {
  const id = setInterval(() => {
    setPlayerPos(prev => {
      console.log("Current player pos", JSON.stringify(prev));
      return { x: 128, y: prev.y + 10 }
    });
  }, 1000);

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

Your other option is to change the way you do the effect so that you set up a new timeout every time the position changes. That way, each new timeout closes over the most recent version of the state.

useEffect(() => {
  const id = setTimeout(() => {
    console.log("Current player pos", JSON.stringify(playerPos));
    setPlayerPos({ x: 128, y: playerPos.y + 10 });
  }, 1000);

  return () => {
    clearTimeout(id);
  }
}, [playerPos]
Related