setInterval + React hooks causing multiple updates within component

Viewed 3222

I'm building a stopwatch UI that shows the time in seconds. With the click of a button, the timer will start counting upwards and stop when it is clicked again. User should be able to start it again.

The issue I'm having is that I can have setInterval working correctly but once I include setTime hook, the component updates to render the time in the UI but the setInterval instance is being called multiple times. This leads to odd rendering behavior.

const Timer = () => {
    const [time, setTime] = useState(0)
    let timer

    const startStopTimer = () => {
        if (!timer) timer = setInterval(() => setTime(time++), 1000)
        else {
           clearInterval(timer)
           timer = null
        }
    }

    return (
            <div>
               <p>Time: {time} seconds</p>
               <Button 
                   onClick={() => {
                      startStopTimer()
                   }
               > Start/Stop </Button>
            </div>
           )
}

Example behavior would be:

  1. User clicks Start/Stop
  2. Timer starts from 0 and counts upward
  3. User clicks Start/Stop
  4. Timer stops immediately
  5. User clicks Start/Stop
  6. Timer continues where it left off
2 Answers

This is a classic example of stale closure in React hooks, inside your setInterval value of time is not changing after calling setTime. Change your code with:

setInterval(() => setTime(currentTime => currentTime + 1), 1000).

setTime just like the setState of classful components also accepts a callback function which has the current value as the first param

Also, the timer variable is useless in you code since on every re-render it will be undefined and you wont't have the access of return value of setInterval, so it will reinitialize the setInterval. To handle that use useRef, you can store the return of setInterval in .current, which will be available to you after subsequent re renders so no more re-init of setInterval and you can also use clearInterval

Solution:

const {useState, useRef} = React;
const {render} = ReactDOM;

const Timer = () => {
  const [time, setTime] = useState(0);
  const timer = useRef(null);
  const startStopTimer = () => {
    if (!timer.current) {
      timer.current = setInterval(() => setTime(currentTime => currentTime + 1), 1000);
    } else {
      clearInterval(timer.current);
      timer.current = null;
    }
  };

  return (
    <div>
      <p>Time: {time} seconds</p>
      <button
        onClick={startStopTimer}
      >
        Start/Stop
      </button>
    </div>
  );
};

render(<Timer />, document.getElementById("root"));
<script src="https://unpkg.com/react@16/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@16/umd/react-dom.production.min.js"></script>
<div id="root"></div>

Here is an example using a react class component. This example keeps track of the start time instead of adding to some value on a certain interval. Then when you stop the timer it accumulates the passed time.

The callback passed to setInterval might not always exactly be called each n ms. If the JavaScript engine is busy it might take a few ms longer. Keeping a counter would slowly offset the actual passed time the longer it runs.

const {Component} = React;
const {render} = ReactDOM;

class StopWatch extends Component {
  state = {startTime: null, accTime: 0, intervalId: null};
  
  componentWillUnmount() {
    clearInterval(this.state.intervalId);
  }
  
  ms() {
    const {startTime, accTime} = this.state;
    if (!startTime) return accTime;
    return Date.now() - startTime + accTime;
  }
  
  start = () => {
    this.setState({
      startTime: Date.now(),
      intervalId: setInterval(() => this.forceUpdate(), 10)
    });
  }
  
  stop = () => {
    clearInterval(this.state.intervalId);
    this.setState({
      startTime: null,
      accTime: this.ms(),
      intervalId: null
    });
  }
  
  reset = () => {
    this.setState({
      accTime: 0,
      startTime: this.state.startTime && Date.now()
    });
  }

  render() {
    return (
      <div>
        <h1>{this.ms() / 1000}</h1>
        {this.state.startTime
          ? <button onClick={this.stop}>stop</button>
          : <button onClick={this.start}>start</button>}
        <button onClick={this.reset}>reset</button>
      </div>
    );
  }
}

render(<StopWatch />, document.getElementById("stop-watch"));
<script src="https://unpkg.com/react@16/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>
<div id="stop-watch"></div>

Related