React useRef() with setInterval() in useCallBack() function not clearing upon clearInterval() - reactjs

I have this stopwatch code, which shows the elapsed time on screen. (React Component)
It all works okay, but calling "clearInterval(increment.current)" (which using using a useRef() for scope in React) doesn't seem to really "stop" the Interval. It keeps logging "Triggered" in the console (every second) - and I don't follow why it's still being called after clearInterval() when currentActivityOn === false. (And if the timer is triggered several times, it triggers more than once per second.)
Any suggestions?
import React, { useEffect, useRef, useCallback, useContext } from 'react'
import Container from '#material-ui/core/Container'
import Typography from '#material-ui/core/Typography'
import useLocalStorage from '../../hooks/useLocalStorage'
import { FlowTimeContext } from '../../services/flow/flow-time.context'
import { useStyles } from './grid-stopwatch.styles'
export default function GridStopwatch() {
const classes = useStyles()
const { currentActivityOn } = useContext(FlowTimeContext)
const [timerStartTime, setTimerStartTime] = useLocalStorage('timerStartTime', '')
const [timer, setTimer] = useLocalStorage('timer', 0)
const increment = useRef(null)
const handleTimerRun = useCallback(() => {
increment.current = setInterval(() => {
if(timerStartTime !== ''){
const new_timer = ((Math.floor(new Date().getTime() / 1000)) - timerStartTime)
setTimer(new_timer)
} else {
console.log("Triggered") // Why is this being called continually?
}
}, 1000)
}, [setTimer, timerStartTime])
const handleReset = useCallback(() => {
clearInterval(increment.current)
setTimer(0)
setTimerStartTime('')
}, [setTimer, setTimerStartTime])
const handleStart = useCallback(() => {
if(timerStartTime === ''){
var start = Math.floor(new Date().getTime() / 1000)
setTimerStartTime(start)
}
}, [timerStartTime, setTimerStartTime])
const formatTime = () => {
const getSeconds = `0${timer % 60}`.slice(-2)
const minutes = `${Math.floor(timer / 60)}`
const getMinutes = `0${minutes % 60}`.slice(-2)
const getHours = `0${Math.floor(timer / 3600)}`.slice(-2)
return `${getHours}h : ${getMinutes}m : ${getSeconds}s`
}
useEffect(() => {
if(currentActivityOn === true){
handleStart()
handleTimerRun()
} else {
handleReset()
}
}, [currentActivityOn, handleStart, handleReset, handleTimerRun])
return (
<Container maxWidth='xl' className={classes.container}>
{currentActivityOn && (
<Typography variant='subtitle2' gutterBottom>
Current Activity Duration: {formatTime()}
</Typography>
)}
</Container>
)
}

So the useEffect gets triggered twice in this case. So handleTimerRun got called twice, and it seems each time there was an additional interval started that was never cleared.
const handleTimerRun = useCallback(() => {
if(timerStartTime !== ''){
increment.current = setInterval(() => {
const new_timer = ((Math.floor(new Date().getTime() / 1000)) - timerStartTime)
setTimer(new_timer)
}, 1000)
}
}, [setTimer, timerStartTime, increment])
For now I'm able to work around this by moving the condition before starting the interval, so it only gets started once. This seems to solve my issue, but is a workaround rather than truly understanding "how" there can be an additional interval started that is assigned the same id. (and seemingly can't be "stopped")
Further clarity on that would certainly be appreciated.
It seems that if I console log increment.current, it keeps increasing - whereas I thought it would be a consistent ID...? (mutable yes, but consistent when not being mutated.)

Related

How to stop useState hook setState re-render in the useEffect? I am trying to code a count down function and the mins can sync from another component

I am trying to code a countdown clock and the "mins" is from a brother component.
If I take off setMin from useEffect then it is not changing while the value change in source components, but if I leave it in useEffect it will re-rendering every time if the seconds change and makes it immutable. Anyway, can I fix this problem?
If I need useRef how can I use it with setMin?
export default function Timer(props) {
const { count } = props;
const [isPlay, setIsPlay] = useState(false);
const [isRestore, setIsRestore] = useState(false);
const [second, setSecond] = useState(0);
const [isBreak, setIsBreak] = useState(false);
const [min, setMin] = useState(count)
useEffect(() => {
setMin(count)
let alarm = document.getElementById("beep");
const countdown = () => {
setTimeout(() => {
if (second === 0 && min > 0) {
setMin(min-1);
setSecond(59);
} else if (min >= 0 && second > 0) {
setSecond(second - 1);
} else {
alarm.play();
alarm.addEventListener("ended", () => {
setIsBreak(!isBreak);
});
}
}, 1000);
};
if (isPlay) {
countdown();
} else {
alarm.pause();
}
if (isRestore) {
setIsPlay(false);
alarm.currentTime = 0;
setIsRestore(false);
}
},[isPlay, isRestore, second, isBreak,min,count]);
I think the majority of your logic should be outside of the useEffect hook. Take a look at this answer Countdown timer in React, it accomplishes pretty much the same task as yours and should help you get an idea of the necessary logic

Switching image src with images from an array on an interval in React

This should be fairly simple, but I keep getting a weird behaviour from the result.
Basically, I have an array of images:
const images = [img1, img2, img3, img4, img5, img6];
I also have an image index:
const [imageIndex, setImageIndex] = useState(0);
Then I do a little incrementation of the index:
const switchImage = () => {
if (imageIndex === images.length - 1) {
setImageIndex(0);
} else {
setImageIndex(imageIndex + 1);
}
return imageIndex;
}
Then I call this function from a useEffect:
useEffect(() => {
setInterval(() => {
switchImage();
}, 1000);
}, []);
And finally I add the html:
<img src={images[imageIndex]} />
The result is usually it gets stuck on the second image and stops incrementing, so I thought the issue might be with the useEffect and the way the component is rendering.
You need to use the second method signature of the useState setter function which gives you the previous state value to avoid the stale closure captured value.
const root = ReactDOM.createRoot(document.getElementById('root'));
const images = ['1','2','3','4','5','6'];
const Thing =()=>{
const [imageIndex, setImageIndex] = React.useState(0);
React.useEffect(() => {
setInterval(() => {
setImageIndex(prev => (
prev === images.length - 1 ? 0 : prev + 1
));
}, 1000);
},[])
console.log(imageIndex)
return (
<div>
<h1>{images[imageIndex]}</h1>
</div>
);
}
root.render(<Thing />);
See here https://codepen.io/drGreen/pen/JjpmQrV
Also worth seeing this link which is virtually identical.
In your case the useEffect which you have created it is only being triggered once; when the component is loading - that is because you did not define when this logic should be triggered by adding dependencies to the useEffect.
Now, since the component renders once, 'switchImage'()' is only being triggered once, hence, it iterates once, display the img and stops.
Here is some good documentation on useEffect if you would like to read more about it Using the Effect Hook - React
๐Ÿ’กHere is a slightly altered solution where we are using the debounce technique for the timer. SOLUTION๐Ÿ’ก
const root = ReactDOM.createRoot(document.getElementById('root'));
const images = ['๐Ÿ’ก','๐Ÿ˜Š','๐Ÿ˜','๐Ÿ˜','๐ŸŽฏ','๐Ÿ‘Œ'];
const DemoComponent = () =>{
const [imageIndex, setImageIndex] = React.useState(0);
//debounce set default 0.3s
const debounce = (func, timeout = 300) =>{
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => { func.apply(this, args); }, timeout);
};
}
// switch img fn.
const switchImage = () => {
setImageIndex(imageIndex === images.length - 1 ? 0 : imageIndex + 1)
return imageIndex;
}
//debounce switchImage and set timer to 1s
const switchImageDebounce = debounce(() => switchImage(),1000);
//useEffect
React.useEffect(() => {
switchImageDebounce()
}, [imageIndex]);
return (
<div>
<h1>{images[imageIndex]}</h1>
</div>
);
}
root.render();

useEffect stops working after the first time useState's set becomes stale within a timer

I essentially want a timer to update a displayed calculated time constantly. If I run the setTimeout faster than the expected time the calculation results in a new number for the time (840ms vs 1000ms), the loop ends forever. How do I make this work with React Hooks? Not looking for the answer of just keeping it at 1000ms. I'm trying to find if my usage of useState and useEffect are incorrect or that there's a better hook that I'm not thinking of.
import { useEffect, useState } from "react";
function App() {
const [display, setDisplay] = useState("0");
useEffect(() => {
console.log("display", display);
function calculateTime() {
console.log('timer', Date.now() - 1626712121266);
return Date.now() - 1626712121266;
}
let timeoutId: NodeJS.Timeout;
timeoutId = setTimeout(() => {
console.log("setTimeout")
setDisplay(displayTime(calculateTime()));
}, 840);
return () => {
clearTimeout(timeoutId);
}
}, [display]);
return (
<>
{display}
</>
);
}
function displayTime(milliseconds: number) {
const seconds = Math.floor(milliseconds / 1000 % 60);
const displaySeconds = (seconds < 10) ? '0' + seconds : seconds;
let displayString = "" + displaySeconds;
console.log('displayString', displayString)
return displayString;
}
export default App;
Console results after a refresh. Notice how once seconds stay the same, useEffect stops being called.
How I created this test:
yarn create react-app test-timer --template typescript
Replaced the App() function with what is shown here.
Converting the useState into useReducer with setInterval as the timing function did the trick. useReducer is still like a magical black box to me but it works for now without a hitch. Would love an explanation why this is happening.
import { useEffect, useReducer } from "react";
function App() {
function reducer(state: any, action: any): any {
if (action.type === 'displayUpdate') {
const display = displayTime(Date.now() - 1626712121266);
return {
display: display,
};
}
}
const [state, dispatch] = useReducer(reducer, { display: "0" });
useEffect(() => {
console.log("useEffect");
let intervalId = setInterval(() => {
console.log("setInterval")
dispatch({type: 'displayUpdate'});
}, 500);
return () => {
console.log('useEffect return')
clearInterval(intervalId);
}
}, []);
return (
<>
{state.display}
</>
);
}
function displayTime(milliseconds: number) {
const seconds = Math.floor(milliseconds / 1000 % 60);
const displaySeconds = (seconds < 10) ? '0' + seconds : seconds;
let displayString = "" + displaySeconds;
console.log('displayString', displayString)
return displayString;
}
export default App;

Prevent re-render with expensive timer

I need a timer in one of my main screens, which has to run every 10ms (which is really expensive, I know. It has to play a sound for a certain cadence).
I tried to use a custom hook to extract the time from the screen like so:
import { useEffect, useState } from "react";
// constants
const PLAYER_INTERVAL = 10;
const useStepSoundInterval = (
receivedCadence: number,
handleSound: () => void,
) => {
const [playerTimeCount, setPlayerTimeCount] = useState(0);
const [playerTimerStep, setPlayerTimerStep] = useState(0);
let timerSound: NodeJS.Timeout;
useEffect(() => {
timerSound = setInterval(handleStepSoundInterval, PLAYER_INTERVAL);
return () => {
clearInterval(timerSound);
setPlayerTimeCount(0);
setPlayerTimerStep(0);
};
}, []);
useEffect(() => {
if (receivedCadence) {
setPlayerTimerStep((playerTimerStep) => playerTimerStep + 0.1);
if (1 / receivedCadence * PLAYER_INTERVAL <= playerTimerStep) {
setPlayerTimerStep(0);
handleSound();
}
}
}, [receivedCadence, playerTimeCount]);
const handleStepSoundInterval = () => {
setPlayerTimeCount((playerTimeCount) => playerTimeCount + 1);
};
};
export default useStepSoundInterval;
Then I call it like that on my screen:
useStepSoundInterval(
receivedCadence,
handleSound,
);
I thought, if I extract it, as a hook, from the screen, it would prevent to re-render the JSX all the time, but it does re-render every 10ms, which is a tiny bit expensive :)
Can I somehow use React.Memo? I failed to implement it.

Is there a reason why my clearInterval function isnt working in React?

I've created a simple timer script initialized with 10 seconds for testing. The problem I'm having is that the pause timer button isn't working as expected.
import React, { useState } from 'react';
function App() {
const [time, updateTime] = useState(() => { return 10 });
const [timerRunning, setTimerRunning] = useState(false);
let minutes = Math.floor(time / 60);
let seconds = time % 60;
let interval;
function startTimer() {
setTimerRunning(true);
interval = setInterval( function() {
updateTime(previousTime => previousTime === 0 ? previousTime : previousTime - 1);
}, 1000);
}
function pauseTimer() {
setTimerRunning(false);
clearInterval(interval);
}
function restartTimer() {
setTimerRunning(false);
updateTime(() => {return 10});
}
return (
<>
<p>{minutes > 9 ? minutes : "0" + minutes}:{seconds > 9 ? seconds : "0" + seconds}</p>
<button onClick={startTimer}>Start</button>
<button onClick={pauseTimer}>Pause</button>
<button onClick={restartTimer}>Restart</button>
</>
)
}
export default App;
I want the pause button to pause the timer. Eventually I'll make conditional statements to have each button appear based on the state of the app and the value of time, but the pause button is my current obstacle.
I first had a separate countdown function which used a conditional to stop the time when the time matched counter (below). I thought of a less complicated way that lets me omit the counter variable (above). Im not sure which option is better, or if either is preventing the clearInterval function to work properly. The clearInterval function works within the countdown function if statement, but will not work outside of it.
import React, { useState } from 'react';
function App() {
const [time, updateTime] = useState(() => { return 10 });
const [timerRunning, setTimerRunning] = useState(false);
let counter = 0;
let minutes = Math.floor(time / 60);
let seconds = time % 60;
let interval;
function countdown() {
counter++;
if ( counter === time ) {
setTimerRunning(false);
clearInterval(interval);
}
updateTime(previousTime => previousTime - 1);
}
function startTimer() {
setTimerRunning(true);
interval = setInterval(countdown, 1000);
}
function pauseTimer() {
setTimerRunning(false);
clearInterval(interval);
}
function restartTimer() {
setTimerRunning(false);
updateTime(() => {return 10});
}
return (
<>
<p>{minutes > 9 ? minutes : "0" + minutes}:{seconds > 9 ? seconds : "0" + seconds}</p>
<button onClick={startTimer}>Start</button>
<button onClick={pauseTimer}>Pause</button>
<button onClick={restartTimer}>Restart</button>
</>
)
}
export default App;
Basically you can't create let interval; and assign it a setInterval like interval = setInterval(countdown, 1000);
because on each re-render there will be new let interval;
what you need to do is create a variable which isn't change on re-redners, you can use useRef
const interval = useRef(null);
.....
function startTimer() {
interval.current = setInterval(countdown, 1000);
}
....
function pauseTimer() {
clearInterval(interval.current);
}
and I don't think you need const [timerRunning, setTimerRunning] = useState(false);
find a demo here
Basically when functional component re-renders it will execute from top to bottom, if you use like let counter = 0;, then on each re-render it will initialize to 0, if you need to persists your values in each re-renders you might need some hooks (Ex: useState, useRef ...), in this case useRef would do the trick (because you need only one setInterval in each re-renders and useRef will give you the previous value, it will not re-initalize like a general variable)
You have to use useEffect, like this:
const handleStart = () => {
setChangeValue(true)
}
const handlePause = () => {
setChangeValue(false)
pauseTimer()
}
const handleRestart = () => {
setInitialState()
setChangeValue(true)
}
useEffect(() => {
if (changeValue) {
const interval = setInterval(() => {
startTimer()
}, 100)
return () => clearInterval(interval)
}
}, [changeValue])
you have three buttons to start, pause and restart, invoke these (handleStart, handlePause, handleRestart) functions with them
that is my solution
instead of the startTime function, I use useEffect
useEffect(()=>{
interval = timerRunning && setInterval(() => {
updateTime(previousTime => previousTime === 0 ? previousTime : previousTime - 1);
}, 1000);
return ()=> clearInterval(interval)
},[timerRunning])
and in onClick Start Button
<button onClick={()=> setTimerRunning(true)}>Start</button>
I hope it is useful

Resources