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>
)
}
Solved!
First, my timeouts didn't need the arrow functions. They should just be:
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...
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.