Search code examples
reactjssetinterval

A stopwatch with React and setInterval: how to fix the missing timer id issue?


I'm trying to create a stopwatch in a React app. Inside a component (well, currently inside App() created with vite), I have the following bits:

const [elapsedTime, setElapsedTime] = useState(0)

// this was just timer: number | undefined, which I've changed to this,
// but it didn't help, keep reading
let timer: { id?: number } = {
    id: undefined,
}
const tickSecond = () => setElapsedTime(prevElapsedTime => prevElapsedTime + 1)
const resumeClock = () => timer.id = window.setInterval(tickSecond, 1000)
const pauseClock = () => {
    window.clearInterval(timer.id)
    timer.id = undefined
}
const toggleClockRun = () => {
    console.log(`toggleClockRun: timer is`,timer)
    timer.id ? pauseClock() : resumeClock()
}
const startClock = () => {
    setElapsedTime(0)
    resumeClock()
    console.log(`startClock: timer is`,timer)
}

and in JSX:

<button onClick={startClock}>start</button>
<button onClick={toggleClockRun}>pause</button>

The expected behavior would be:

  • click start starts the stopwatch;
  • click pause pauses it (well, toggles pause, but it's not important in this context).

While startClock reports startClock: timer is {id: 35} (evaluated at the point of logging), toggleClockRun unexpectedly reports startClock: timer is {id: undefined}. How comes? Both when storing the timer id inside timer as a number (timer = window.setInterval(..)) or as the id property of {id}, inside toggleClockRun although there's no other assigning of its value. Why timer.id becomes undefined?

PS For debugging, I've also tried to add {timer.id} to JSX, and it never shown a non-empty value (but that's probably because editing the value didn't cause re-rendering).


Solution

  • The timer reference needs to be persisted between renders. The useRef hook is used to store values that do not cause a re-render when updated.

    const {useReducer, useRef} = React;
    
    function reducer(currentState, newState) {
      return {...currentState, ...newState};
    }
    
    function Stopwatch() {
      const [{running, lapsedTime}, setState] = useReducer(reducer, {
        running: false,
        lapsedTime: 0,
      });
    
      const timerRef = useRef(null);
    
      function toggleClockRun() {
        if (running) {
          clearInterval(timerRef.current);
        } else {
          const startTime = Date.now() - lapsedTime;
          timerRef.current = setInterval(() => {
            setState({lapsedTime: Date.now() - startTime});
          }, 0)
        }
        setState({running: !running});
      }
    
      function resetButtonClick() {
        clearInterval(timerRef.current);
        setState({lapsedTime: 0, running: false});
      }
    
      return (
        <div style={{textAlign: 'center'}}>
          <label
            style={{
              display: 'block',
            }}
          >
            {lapsedTime}
          </label>
          <button onClick={toggleClockRun}>
            {running ? 'Stop' : 'Start'}
          </button>
          <button onClick={resetButtonClick}>
            Reset
          </button>
        </div>
      )
    }
    
    ReactDOM.render(<Stopwatch />, document.getElementById("app"));