Issue clearing a recursive timeout with onClick in React - reactjs

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

Related

Facing problems in creating a Timer in React Js

let [seconds,setSeconds] = useState(59) useEffect(()=>{ setInterval(()=>{ setSeconds(seconds-1) },1000) })
Passing {seconds} in html timer starts. But it works like 59-58 and then it is decreasing along with different numbers. I need solution for this.
I tried using loops and other methods but didn't work.
I was expecting 59-58-57-56 to 00
The issue is that every time seconds changes your components gets re-rendered creating multiple instances of setInterval.
You need to wrap your setInterval in a useEffect in order to be able to clear it properly.
React.useEffect(() => {
const timer =
seconds > 0 && setInterval(() => setSeconds(seconds - 1), 1000);
return () => clearInterval(timer);
}, [seconds]);

Stale State in useEffect and setInterval

So I am working on an app, which as a main functionality uses a timer.
However as the timer should not be user manipulated it shall run on the backend, this is being achieved with firebase cloud functions and server timestamps.
Currently I am using an useEffect Hook on the Page which intializes the Timer with setInterval and an empty dependency Array so that the timer gets only Started once on Component mount. In the setInterval Callback there is a function called "getPoolTime", this async function calls a cloudFunction that writes a new serverTimestamp to firestore and calculates the remaining time, based on other factors. (Probably not the most efficient way to do this, but it was the only way I could figure out for now xD ).
The problem I have now is, even if the timer ran out, setInterval still calls "getPoolTime" which invokes a cloud function and this is not very efficient. I want the cloud function not being invoked when the timer ran out.
I tried:
if Statement in the useEffect Hook, so when the timer state is say < 1 do not call "getPoolTime" anymore, however as the the timer only mounts at component mount, there is the problem of stale state and therefore this does not work.
If Statement in the "getPoolTime" function, which leads to the problem that once the timer has reached zero it can never be restarted as even if the function is invoked the state is < 1 doesn't pass the if check and the new time is never calculated.
So I guess the solution I need is, something that starts the timer and calls the "getPoolTime" on mount until timerDisplay < 1 and on mount calls "getPoolTime" once again.
Thank you for your help - I am well aware that what I am doing is probably really inefficient and there are certainly better ways to do it - if you look at my code and have an idea how I could make it better, let me know - I am eager to learn!
const [timerDisplay, setTimerDisplay] = useState(0);
async function getPoolTime() {
try {
const setWriteTimestamp = httpsCallable(functions, "setWriteTimestamp");
setWriteTimestamp({ slug: slug });
const poolRef = doc(db, "pools", slug);
const poolSnap = await getDoc(poolRef);
const timeLeft =
poolSnap.data().expTimestamp.toMillis() / 1000 -
poolSnap.data().writeTimestamp.toMillis() / 1000;
console.log(timeLeft);
setTimerDisplay(Math.floor(timeLeft));
} catch (error) {
console.log("Error occured in getting Pool Time", error);
}
}
useEffect(() => {
const interval = setInterval(() => {
getPoolTime();
}, 1100);
return () => {
clearInterval(interval);
};
}, []);

Function is executing even after leaving the page React js

When I navigate from home i.e, "/" to "/realtime" useEffect hook start the video from webcam, then I added a function handleVideoPlay for video onPlay event as shown below.
<video
className="video"
ref={videoRef}
autoPlay
muted
onPlay={handleVideoPlay}
/>
For every interval of 100ms, the code inside setInterval( which is inside the handleVideoPlay function) will run, which detects facial emotions using faceapi and draw canvas.
Here is my handleVideoPlay function
const [ isOnPage, setIsOnPage] = useState(true);
const handleVideoPlay = () => {
setInterval(async () => {
if(isOnPage){
canvasRef.current.innerHTML = faceapi.createCanvasFromMedia(videoRef.current);
const displaySize = {
width: videoWidth,
height: videoHeight,
};
faceapi.matchDimensions(canvasRef.current, displaySize);
const detections = await faceapi.detectAllFaces(videoRef.current, new
faceapi.TinyFaceDetectorOptions()).withFaceLandmarks().withFaceExpressions();
const resizeDetections = faceapi.resizeResults(detections, displaySize);
canvasRef.current.getContext("2d").clearRect(0, 0, videoWidth, videoHeight);
faceapi.draw.drawDetections(canvasRef.current, resizeDetections);
faceapi.draw.drawFaceLandmarks(canvasRef.current, resizeDetections);
faceapi.draw.drawFaceExpressions(canvasRef.current, resizeDetections);
}else{
return;
}
}, 100);
};
The problem is when I go back the handleVideoFunction is still running, so for canvasRef it is getting null value, and it's throwing this error as shown below
Unhandled Rejection (TypeError): Cannot read property 'getContext' of null
I want to stop the setInterval block on leaving the page. I tried by putting a state isOnPage to true and
I set it to false in useEffect cleanup so that if isOnPage is true the code in setInterval runs else it returns. but that doesn't worked. The other code in useEffect cleanup function is running but the state is not changing.
Please help me with this, and I'm sorry if haven't asked the question correctly and I'll give you if you need more information about this to resolve.
Thank you
You need to clear your setInterval from running when the component is unmounted.
You can do this using the useEffect hook:
useEffect(() => {
const interval = setInterval(() => {
console.log('I will log every second until I am cleared');
}, 1000);
return () => clearInterval(interval);
}, []);
Passing an empty array to useEffect ensures the effect will only trigger once when it is mounted.
The return of the effect is called when the component is unmounted.
If you clear the interval here, you will no longer have the interval running once the component is unmounted. Not only that, but you are ensuring that you are not leaking memory (i.e. by indefinitely increasing the number of setIntervals that are running in the background).

React state not updating inside setInterval

I'm trying to learn React with some simple projects and can't seem to get my head around the following code, so would appreciate an explanation.
This snippet from a simple countdown function works fine; however, when I console.log, the setTime appears to correctly update the value of 'seconds', but when I console.log(time) immediately after it gives me the original value of 3. Why is this?
Bonus question - when the function startCountdown is called there is a delay in the correct time values appearing in my JSX, which I assume is down to the variable 'seconds' being populated and the start of the setInterval function, so I don't get a smooth and accurate start to the countdown. Is there a way around this?
const [ time, setTime ] = useState(3);
const [ clockActive, setClockActive ] = useState(false);
function startCountdown() {
let seconds = time * 60;
setClockActive(true);
let interval = setInterval(() => {
setTime(seconds--);
console.log(seconds); // Returns 179
console.log(time); // Returns 3
if(seconds < 0 ) {
clearInterval(interval);
}
}, 1000)
};
Update:
The reason you are not seeing the correct value in your function is the way that setState happens(setTime). When you call setState, it batches the calls and performs them when it wants to in the background. So you cannot call setState then immediately expect to be able to use its value inside of the function.
You can Take the console.log out of the function and put it in the render method and you will see the correct value.
Or you can try useEffect like this.
//This means that anytime you use setTime and the component is updated, print the current value of time. Only do this when time changes.
useEffect(()=>{
console.log(time);
},[time]);
Every time you setState you are rerendering the component which causes a havoc on state. So every second inside of your setInterval, you are re-rendering the component and starting it all over again ontop of what you already having running. To fix this, you need to use useEffect and pass in the state variables that you are using. I did an example for you here:
https://codesandbox.io/s/jolly-keller-qfwmx?file=/src/clock.js
import React, { useState, useEffect } from "react";
const Clock = (props) => {
const [time, setTime] = useState(3);
const [clockActive, setClockActive] = useState(false);
useEffect(() => {
let seconds = 60;
setClockActive(true);
const interval = setInterval(() => {
setTime((time) => time - 1);
}, 1000);
if (time <= 0) {
setClockActive(false);
clearInterval(interval);
}
return () => {
setClockActive(false);
clearInterval(interval);
};
}, [time, clockActive]);
return (
<>
{`Clock is currently ${clockActive === true ? "Active" : "Not Active"}`}
<br />
{`Time is ${time}`}
</>
);
};
export default Clock;

Jest: setTimeout is being called too many times

I am testing a react component that uses setTimeout. The problem is that Jest is saying that setTimeout is called even though it clearly isn't. There is a setTimeout to remove something from the ui and another one to pause the timer when the mouse is hovering over the component.
I tried adding a console.log() where the setTimeout is and the console log is never called, which means the setTimeout in the app isn't being called.
//app
const App = (props) => {
const [show, setShow] = useState(true);
const date = useRef(Date.now());
const remaining = useRef(props.duration);
let timeout;
useEffect(() => {
console.log('Should not run');
if (props.duration) {
timeout = setTimeout(() => {
setShow(false)
}, props.duration);
}
}, [props.duration]);
const pause = () => {
remaining.current -= Date.now() - date.current;
clearTimeout(timeout);
}
const play = () => {
date.current = Date.now();
clearTimeout(timeout);
console.log('should not run');
timeout = setTimeout(() => {
setIn(false);
}, remaining.current);
}
return (
<div onMouseOver={pause} onMouseLeave={play}>
{ show &&
props.content
}
</div>
)
}
//test
it('Should not setTimeout when duration is false', () => {
render(<Toast content="" duration={false} />);
//setTimeout is called once but does not come from App
expect(setTimeout).toHaveBeenCalledTimes(0);
});
it('Should pause the timer when pauseOnHover is true', () => {
const { container } = render(<Toast content="" pauseOnHover={true} />);
fireEvent.mouseOver(container.firstChild);
expect(clearTimeout).toHaveBeenCalledTimes(1);
fireEvent.mouseLeave(container.firstChild);
//setTimeout is called 3 times but does not come from App
expect(setTimeout).toHaveBeenCalledTimes(1);
});
So in the first test, setTimeout shouldn't be called but I receive that its called once. In the second test, setTimeout should be called once but is called 3 times. The app works fine I just don't understand what is going on with jest suggesting that setTimeout is being called more than it is.
I'm experiencing the exact same issue with the first of my Jest test always calling setTimeout once (without my component triggering it). By logging the arguments of this "unknown" setTimeout call, I found out it is invoked with a _flushCallback function and a delay of 0.
Looking into the repository of react-test-renderer shows a _flushCallback function is defined here. The Scheduler where _flushCallback is part of clearly states that it uses setTimeout when it runs in a non-DOM environment (which is the case when doing Jest tests).
I don't know how to properly proceed on researching this, for now, it seems like tests for the amount of times setTimeout is called are unreliable.
Thanks to #thabemmz for researching the cause of this, I have a hacked-together solution:
function countSetTimeoutCalls() {
return setTimeout.mock.calls.filter(([fn, t]) => (
t !== 0 ||
!String(fn).includes('_flushCallback')
));
}
Usage:
// expect(setTimeout).toHaveBeenCalledTimes(2);
// becomes:
expect(countSetTimeoutCalls()).toHaveLength(2);
It should be pretty clear what the code is doing; it filters out all calls which look like they are from that react-test-renderer line (i.e. the function contains _flushCallback and the timeout is 0.
It's brittle to changes in react-test-renderer's behaviour (or even function naming), but does the trick for now at least.

Resources