Issue clearing a recursive timeout with onClick in React

124 views Asked by At

I'm rebuilding a special type of metronome I built in vanilla js, with React. Everything is working, except when a user clicks the 'STOP' button, the metronome doesn't stop. It seems I'm losing the timeout ID on re-renders, so clearTimeout is not working. This is a recursive timeout, so it calls itself after each timeout acting more like setInterval, except for it's adjusting the interval each time, thus I had to use setTimeout.

I've tried to save the timeoutID useing setState, but if I do that from within the useEffect hook, there's an infinite loop. How can I save the timerID and clear it onClick?

The code below is a simplifed version. The same thing is on codepen here. The codepen does not have any UI or audio assets, so it doesn't run anything. It's just a gist of the larger project to convey the issue. You can also view the vanilla js version that works.

    import { useState, useEffect } from 'React';

function startStopMetronome(props) {
  const playDrum = 
    new Audio("./sounds/drum.wav").play();
  };

  let tempo = 100; // beats per minute
  let msTempo = 60000 / tempo;
  let drift;
  
  const [isRunning, setIsRunning] = useState(false);

  useEffect(() => {
    let timeout;
    let expected;
    
    const round = () => {
      playDrum();
      // Increment expected time by time interval for every round after running the callback function.
      // The drift will be the current moment in time for this round minus the expected time.
      let drift = Date.now() - expected;
      expected += msTempo;
      // Run timeout again and set the timeInterval of the next iteration to the original time interval minus the drift.
      timeout = () => setTimeout(round, msTempo - drift);
      timeout();
    };

    // Add method to start metronome
    if (isRunning) {
      // Set the expected time. The moment in time we start the timer plus whatever the time interval is. 
      expected = Date.now() + msTempo;
      timeout = () => setTimeout(round, msTempo);
      timeout();
    };
    // Add method to stop timer
    if (!isRunning) {
      clearTimeout(timeout);
    };
  });

  const handleClick = (e) => {
      setIsRunning(!isRunning);
  };

  return (
      <div 
      onClick={handleClick}
      className="start-stop"
      children={isRunning ? 'STOP' : 'START'}>
      </div>
  )
}
1

There are 1 answers

0
Michael Galen On

Solved!

First, my timeouts didn't need the arrow functions. They should just be:

timeout = setTimeout(round, msTempo);

Second, a return in the useEffect block executes at the next re-render. The app will re-render (i thought is would be immediate). So, I added...

 return () => clearTimeout(timeout);

to the bottom of the useEffect block. Lastly, added the dependencies for my useEffect block to ensure it didn't fire on the wrong render.

[isRunning, subdivisions, msTempo, beatCount]);