Shnick
Shnick

Reputation: 1391

State within useEffect not updating

I'm currently building a timer in ReactJS for practice. Currently I have two components, a Timer component which displays the time as well as sets an interval upon mounting. I also have an App component which keeps tracks of the main state, such as state for whether the timer is paused as well as the current value of the timer.

My goal is to make it so that when I click the pause button, the timer stops incrementing. Currently I've tried to achieve this by using:

if(!paused) 
  tick(time => time+1);

which in my mind, should only increment the time state when paused is false. However, when I update the paused state by clicking on my button, this paused state inside the setTimeout does not change. My guess that setTimeout is forming a closure over the paused state, so it's not updating when the state changes. I tried adding paused as a dependency to useEffect but this caused multiple timeouts to be queued whenever paused changed.

const {useState, useEffect} = React;

const Timer = ({time, paused, tick}) => {
  useEffect(() => {
    const timer = setInterval(() => {
      if(!paused) // `paused` doesn't change?
        tick(time => time+1);
    }, 1000);
  }, []);
  
  return <p>{time}s</p>
}

const App = () => {
  const [time, setTime] = useState(0);
  const [paused, setPaused] = useState(false);
  const togglePaused = () => setPaused(paused => !paused);
  
  return (
    <div>
      <Timer paused={paused} time={time} tick={setTime} />
      <button onClick={togglePaused}>{paused ? 'Play' : 'Pause'}</button>
    </div>
  );
}

ReactDOM.render(<App />, document.body);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.4/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.4/umd/react-dom.production.min.js"></script>

So, my question is:

  1. Why isn't my current code working (why is paused not updating within useEffect)?
  2. Is there any way to make the above code work so that I can "pause" my interval which is set within the useEffect()?

Upvotes: 1

Views: 2230

Answers (3)

macborowy
macborowy

Reputation: 1534

To stop the timer return the function clearing the interval from useEffect().

React performs the cleanup when the component unmounts. However, as we learned earlier, effects run for every render and not just once. This is why React also cleans up effects from the previous render before running the effects next time.

(source: Using the Effect Hook - Effects with Cleanup)

You should also pass the paused in dependencies array to stop useEffect creating new intervals on each re-render. If you add [paused] it'll only create new interval when paused change.

const {useState, useEffect} = React;

const Timer = ({time, paused, tick}) => {
  useEffect(() => {
    const timer = setInterval(() => {
      if(!paused) // `paused` doesn't change?
        tick(time => time+1);
    }, 1000);
    
    return () => clearInterval(timer);
  }, [paused]);
 
  return <p>{time}s</p>
}

const App = () => {
  const [time, setTime] = useState(0);
  const [paused, setPaused] = useState(false);
  const togglePaused = () => setPaused(paused => !paused);
  
  return (
    <div>
      <Timer paused={paused} time={time} tick={setTime} />
      <button onClick={togglePaused}>{paused ? 'Play' : 'Pause'}</button>
    </div>
  );
}

ReactDOM.render(<App />, document.getElementById("root"));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.4/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.4/umd/react-dom.production.min.js"></script>
<div id="root"></div>

Upvotes: 4

Ioannis Potouridis
Ioannis Potouridis

Reputation: 1316

You can use Dan's useInterval hook. I can't explain it better than him why you should use this.

The hook looks like this.

function useInterval(callback, delay) {
  const savedCallback = React.useRef();

  // Remember the latest callback.
  React.useEffect(() => {
    savedCallback.current = callback;
  }, [callback]);

  // Set up the interval.
  React.useEffect(() => {
    function tick() {
      savedCallback.current();
    }

    if (delay !== null) {
      let id = setInterval(tick, delay);
      return () => clearInterval(id);
    }
  }, [delay]);
}

Then use it in your Timer component like this.

const Timer = ({ time, paused, tick }) => {
  useInterval(() => {
    if (!paused) tick(time => time + 1);
  }, 1000);

  return <p>{time}s</p>;
};

I believe the difference is that useInterval hook is aware of its dependencies (paused) while setInterval isn't.

Upvotes: 2

Khaled Mohamed
Khaled Mohamed

Reputation: 406

The setInterval captures the paused value the first time, you'll need to remove the interval, recreate it everytime paused is changed.

You can check this article for more info: https://overreacted.io/making-setinterval-declarative-with-react-hooks

Upvotes: 2

Related Questions