1

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>
  )
}
  • You can use [useRef](https://reactjs.org/docs/hooks-reference.html#useref) – Noam Jun 08 '22 at 18:52
  • The if else statement in here is uslese : `const handleClick = (e) => { if (isRunning) { setIsRunning(!isRunning); } else { setIsRunning(!isRunning); }; };` – Omar Dieh Jun 08 '22 at 19:04
  • The codepen link you shared isn't working like you have described, I am not sure if this will fix your issue but you can give it a try, this will always set the opposite state of isRunning : `const handleClick = (e) => { setIsRunning(!isRunning); };` – Omar Dieh Jun 08 '22 at 19:06
  • @OmarDieh yeah good call. I copied and pasted pieces from the larger project, which had more going on in the if else blocks. I'll update it here to be more distilled. – Michael Galen Jun 08 '22 at 21:09
  • @Noam - I tried useRef. I made my timeouts in the useEffect block equal the useRef that is declared outside the useEffect block. So in the useEffect block: `const timeoutRef = () => setTimeout(round, msTempo - drift);` ... then `clearTimeout(timeoutRef.current);` but that didn't work. – Michael Galen Jun 08 '22 at 21:48
  • **Edit:** Nevermind I see you solved it. – Noam Jun 09 '22 at 01:32

1 Answers1

0

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]);