setInterval + React hooks causing multiple updates within component - javascript

I'm building a stopwatch UI that shows the time in seconds. With the click of a button, the timer will start counting upwards and stop when it is clicked again. User should be able to start it again.
The issue I'm having is that I can have setInterval working correctly but once I include setTime hook, the component updates to render the time in the UI but the setInterval instance is being called multiple times. This leads to odd rendering behavior.
const Timer = () => {
const [time, setTime] = useState(0)
let timer
const startStopTimer = () => {
if (!timer) timer = setInterval(() => setTime(time++), 1000)
else {
clearInterval(timer)
timer = null
}
}
return (
<div>
<p>Time: {time} seconds</p>
<Button
onClick={() => {
startStopTimer()
}
> Start/Stop </Button>
</div>
)
}
Example behavior would be:
User clicks Start/Stop
Timer starts from 0 and counts upward
User clicks Start/Stop
Timer stops immediately
User clicks Start/Stop
Timer continues where it left off

This is a classic example of stale closure in React hooks, inside your setInterval value of time is not changing after calling setTime. Change your code with:
setInterval(() => setTime(currentTime => currentTime + 1), 1000).
setTime just like the setState of classful components also accepts a callback function which has the current value as the first param
Also, the timer variable is useless in you code since on every re-render it will be undefined and you wont't have the access of return value of setInterval, so it will reinitialize the setInterval. To handle that use useRef, you can store the return of setInterval in .current, which will be available to you after subsequent re renders so no more re-init of setInterval and you can also use clearInterval
Solution:
const {useState, useRef} = React;
const {render} = ReactDOM;
const Timer = () => {
const [time, setTime] = useState(0);
const timer = useRef(null);
const startStopTimer = () => {
if (!timer.current) {
timer.current = setInterval(() => setTime(currentTime => currentTime + 1), 1000);
} else {
clearInterval(timer.current);
timer.current = null;
}
};
return (
<div>
<p>Time: {time} seconds</p>
<button
onClick={startStopTimer}
>
Start/Stop
</button>
</div>
);
};
render(<Timer />, document.getElementById("root"));
<script src="https://unpkg.com/react#16/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom#16/umd/react-dom.production.min.js"></script>
<div id="root"></div>

Here is an example using a react class component. This example keeps track of the start time instead of adding to some value on a certain interval. Then when you stop the timer it accumulates the passed time.
The callback passed to setInterval might not always exactly be called each n ms. If the JavaScript engine is busy it might take a few ms longer. Keeping a counter would slowly offset the actual passed time the longer it runs.
const {Component} = React;
const {render} = ReactDOM;
class StopWatch extends Component {
state = {startTime: null, accTime: 0, intervalId: null};
componentWillUnmount() {
clearInterval(this.state.intervalId);
}
ms() {
const {startTime, accTime} = this.state;
if (!startTime) return accTime;
return Date.now() - startTime + accTime;
}
start = () => {
this.setState({
startTime: Date.now(),
intervalId: setInterval(() => this.forceUpdate(), 10)
});
}
stop = () => {
clearInterval(this.state.intervalId);
this.setState({
startTime: null,
accTime: this.ms(),
intervalId: null
});
}
reset = () => {
this.setState({
accTime: 0,
startTime: this.state.startTime && Date.now()
});
}
render() {
return (
<div>
<h1>{this.ms() / 1000}</h1>
{this.state.startTime
? <button onClick={this.stop}>stop</button>
: <button onClick={this.start}>start</button>}
<button onClick={this.reset}>reset</button>
</div>
);
}
}
render(<StopWatch />, document.getElementById("stop-watch"));
<script src="https://unpkg.com/react#16/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom#16/umd/react-dom.development.js"></script>
<div id="stop-watch"></div>

Related

How setInterval works in the background of React Re-Rendering

I'm creating a simple countdown timer app with React and I am having hard time understanding how setInterval works while React re-renders the component.
For example, this following code had timer continue to run even though I had used clearInterval onPause().
let startTimer;
const onStart = () => {
startTimer = setInterval( ()=>{
if ( timeRemaining === 0 ) {
clearInterval(startTimer);
setIsCounting(false)
return
}
updateTimer()
}, 1000)
setIsCounting( (prev) => !prev )
} // end of onStart
const onPause = () => {
setIsCounting( (prev) => !prev )
clearInterval(startTimer)
}
return (
{ props.isCounting ?
<button onClick={props.onPause}> Pause </button>
: <button onClick={props.onStart}> Start </button> }
)
However, the timer successfully pauses when I simply change
let starter;
to
let startTimer = useRef(null)
const onStart = () => {
startTimer.current = setInterval( ()=>{
if ( timeRemaining === 0 ) {
clearInterval(startTimer);
setIsCounting(false)
return
}
updateTimer()
}, 1000)
setIsCounting( (prev) => !prev )
} // end of onStart
const onPause = () => {
setIsCounting( (prev) => !prev )
clearInterval(startTimer.current)
}
What's happening to setInterval when React re-renders its component? Why did my timer continue to run when I didn't use useRef()?
A ref provides what's essentially an instance variable over the lifetime of a component. Without that, all that you have inside an asynchronous React function is references to variables as they were at a certain render. They're not persistent over different renders, unless explicitly done through the call of a state setter or through the assignment to a ref, or something like that.
Doing
let startTimer;
const onStart = () => {
startTimer = setInterval( ()=>{
could only even possibly work if the code that eventually calls clearInterval is created at the same render that this setInterval is created.
If you create a variable local to a given render:
let startTimer;
and then call a state setter, causing a re-render:
setIsCounting( (prev) => !prev )
Then, due to the re-render, the whole component's function will run again, resulting in the let startTimer; line running again - so it'll have a value of undefined then (and not the value to which it was reassigned on the previous render).
So, you need a ref or state to make sure a value persists through multiple renders. No matter the problem, reassigning a variable declared at the top level of a component is almost never the right choice in React.

Best way to use setInterval inside React useEffect in countdown timer

newbie to Javascript & React, trying to learn my way through React by building small projects. I'm working on a countdown timer right now, and I'm wondering if there's a better way of handling the pause/stop logic.
Right now, I'm calling setInterval() when my component is no longer in a "Paused" !paused state, and returning a cleanup() function to clear the interval. When I console logged it, it only cleared interval on pause/start.
However, I want to add functionality to automatically stop the timer once it hits zero, which requires adding duration state as a second dependency. When I logged this to console, I realized that my setDuration and clearInterval was now running at every interval, rather than only on the pause/start (it looks like it's calling setInterval() at every "tick" now.
My question is - is this a problem? And is there a better way to do this?
function Timer(props) {
const [duration, setDuration] = useState(10);
const [paused, setPaused] = useState(true);
useEffect(() => {
let timerId;
if (!paused) {
timerId = setInterval(() => {
setDuration(prev => prev - 1);
}, 1000);
// console.log(timerId);
}
if (duration === 0) {
// console.log(`Time's up`);
clearInterval(timerId);
}
return function cleanup() {
// console.log(`Clearing ${timerId}`);
clearInterval(timerId);
};
}, [paused, duration]);
const handleClick = (e) => {
!paused ? setPaused(true) : setPaused(false);
};
return (
<>
<h3>{duration}</h3>
<Button paused={paused} onClick={handleClick} />
</>
);
}
In your code, duration is changing on every interval and because it is passed as a parameter in dependecy array, it is triggering useEffect on each interval which is causing it to the setDuration and clearInterval to run at each interval.
you can use ternary operator to check if the duration is less than zero then you can automatically set the duration to zero by using setDuration(0)
function Timer(props) {
const [duration, setDuration] = useState(10);
const [paused, setPaused] = useState(true);
useEffect(() => {
let timerId;
if (!paused) {
timerId = setInterval(() => {
setDuration((prev) => prev - 1);
}, 1000);
console.log(timerId);
}
return function cleanup() {
console.log(`Clearing ${timerId}`);
clearInterval(timerId);
};
}, [paused]);
const handleClick = (e) => {
!paused ? setPaused(true) : setPaused(false);
};
return (
<>
{duration >= 0 ? <h3>{duration}</h3> : setDuration(0)}
<button paused={paused} onClick={handleClick} />
</>
);
}
Play around with the code here

setInterval start and stop behavior

I'm creating a stopwatch that has a button to start and stop the time, but I'm having trouble with setInterval behavior.
When declared in React's functional component level, it will run once it's mounted.
Example:
const Timer = () => {
const timer = setInterval(() => console.log('running'), 1000)
}
When I declare it inside a function, it will not run until the function is called but then I can't get it to stop.
const Timer = () => {
const [start, setStart] = useState(false)
const startStopTimer = () => {
const timer = setInterval(() => console.log('running'), 1000)
}
return (<Button
onClick={() => {
setStarted(!start)
startStopTimer()
}
> Start/Stop </Button>)
}
I've then tried adding clearInterval() in the function and calling it conditionally if start === false. In this implementation, the first button click does nothing. Second click starts the timer, but it can't be stopped.
const Timer = () => {
const [start, setStart] = useState(false)
const startStopTimer = () => {
let timer = setInterval(() => console.log('running'), 1000)
if (!started) clearInterval(timer)
}
return (<Button
onClick={() => {
setStarted(!start)
startStopTimer()
}
> Start/Stop </Button>)
}
Ciao, I suggest you to modify your code like this:
const Timer = () => {
//const [start, setStart] = useState(false) do not use state for scripting reasons
let timer = null;
const startStopTimer = () => {
if(!timer) timer = setInterval(() => console.log('running'), 1000)
else {
clearInterval(timer)
timer = null
}
}
return (<Button
onClick={() => {
//setStarted(!start)
startStopTimer()
}
> Start/Stop </Button>)
}
Explanation: timer should be defined outside the startStopTimer otherwise, every time you launch startStopTimer, you create a new timer.
Ok this was easy, but now the important part: I strongly suggest you to not use react state for scripting reason. react state should be used only for rendering reasons. Why? Because hook are async and if you read start immediately after use setStart, you will read an old value.
timer variable is local and only scoped inside sartStopTimer() every time you call sartStopTimer() it generates new timer and when clearing timeout you are only clearing the recently generated one not previous timer
let timer;
const startStopTimer = () => {
timer = setInterval(() => console.log('running'), 1000)
if (!started) clearInterval(timer)
}
Made timer global variable.
This should get you started.
Try this:
const Timer = () => {
const [timerId, setTimerId] = useState(null)
function startStopTimer(){
if (timerId) {
clearInterval(timerId)
setTimerId(null)
} else {
setTimerId(setInterval(() => console.log('hello'), 1000))
}
}
return <button onClick={startStopTimer}> Start/Stop </button>
}
There is no need to use state, by the way. In case you do not need to keep the timer state you can definitely use plain variable, of course.

React state repeatedly reverts back to old value

There is a component "DateForm" that changes the global state of "counterInfo" when the form is submitted.
//DateForm component submittal function.
const submitDate = () =>{
props.setCounterInfo(dateInfo); //passes date info to be counterInfo state in App.js
props.setShowInputForm(false); //DateInfo component is no longer rendered
}
then, in app.js the counterInfo state is passed to the Timer component
const App = () => {
const [showInputForm, setShowInputForm] = useState(false);
const [counterInfo, setCounterInfo] = useState(undefined);
return (
<>
<Timer
counterInfo = {counterInfo}
></Timer>
{showInputForm &&
<DateForm
setShowInputForm = {setShowInputForm}
setCounterInfo = {setCounterInfo}
></DateForm>}
</>
);
}
There is a useEffect hook inside of the Timer function that, on a one second interval, used the value of counterInfo.
//Inside the Timer Component
const [currTime, setCurrTime] = useState(null);
useEffect (() => {
setInterval(() => {
let timeLeft = (new Date(`${Months(props.counterInfo.year)[props.counterInfo.month-1].name} ${props.counterInfo.day} ${props.counterInfo.year} ${props.counterInfo.hour}:${props.counterInfo.minute}:${props.counterInfo.second}`).getTime()) - new Date().getTime();
setCurrTime(timeLeft);
},1000);
return(clearInterval());
}, [props, setCurrTime]);
What I intended to happen is for the value of timeLeft in Timer.js to update when the value of counterInfo is updated in DateForm, however, when the value is changed in DateForm, the result of both the new value of counterInfo and the old one both flash when the value of timeLeft is used in Timer.js. This issue isn't caused by any code in Timer.js becuase I tried moving the useEffect hook to app.js and passing the value down to Timer but the problem persisted. The only place that the setCounterInfo state is changed is in the DateForm component.
Does anyone have any idea how to fix this?
First, you have bit mis-syntax at interval decleration
useEffect (() => {
let interval = setInterval(() => {...},1000);
return () => clearInterval(interval);
}, [props, setCurrTime]);
But unrelated, React by default re-applies effects after every render. This is intentional and helps avoid a whole class of bugs that are present in React components.
When it comes to intervals, its specifical matters cause if a render was applied every time setInterval is called, it never will get a chance to actually run
In other words, this code might have some side effects as useEffect in each run cares only of the existing values in that time and forget everything else, and interval isn't like so.
For that from my point of view the best practice is to create useInterval custom hook, that inside will store the callback for the meanwhile
function useInterval(callback) {
const savedCallback = React.useRef();
useEffect(() => {
savedCallback.current = callback;
});
useEffect(() => {
function run() {
savedCallback.current();
}
let interval = setInterval(run ,1000);
return () => clearInterval(interval);
}, [])
}
//Inside the Timer Component
const [currTime, setCurrTime] = useState(null);
useInterval(()=>
setCurrTime((new Date(`${Months(props.counterInfo.year)[props.counterInfo.month-1].name} ${props.counterInfo.day} ${props.counterInfo.year} ${props.counterInfo.hour}:${props.counterInfo.minute}:${props.counterInfo.second}`).getTime()) - new Date().getTime()))

State doesn't update inside setInterval

I'm making a simple progress bar and I've noticed strange behaviour of hook state when I'm using setInterval. Here is my sample code:
const {useState} = React;
const Example = ({title}) => {
const [count, setCount] = useState(0);
const [countInterval, setCountInterval] = useState(0)
let intervalID
const handleCount = () => {
setCount(count + 1)
console.log(count)
}
const progress = () => {
intervalID = setInterval(() => {
setCountInterval(countInterval => countInterval + 1)
console.log(countInterval)
if(countInterval > 100) { // this is never reached
setCountInterval(0)
clearInterval(intervalID)
}
},100)
}
const stopInterval = () => {
clearInterval(intervalID)
}
return (
<div>
<p>{title}</p>
<p>You clicked {count} times</p>
<p>setInterval count { countInterval } times</p>
<button onClick={handleCount}>
Click me
</button>
<button onClick={progress}>
Run interval
</button>
<button onClick={stopInterval}>
Stop interval
</button>
</div>
);
};
// Render it
ReactDOM.render(
<Example title="Example using simple hook:" />,
document.getElementById("app")
);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.4/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.4/umd/react-dom.production.min.js"></script>
<div id="app"></div>
If I set state by handleCount everything is happen as expected, but when I'm running progress function, inside setInterval countInterval value doesn't change at all. Regardless of it, countInterval has changed in the state.
To get this around I'm using variable inside progress function, like this:
const progress = () => {
let internalValue = 0
intervalID = setInterval(() => {
setCountInterval(internalValue)
internalValue++
if(internalValue > 100) {
setCountInterval(0)
clearInterval(intervalID)
}
},100)
}
And that works fine, but I'm still wondering if there is a better approach and what I'm doing wrong in the first case.
The second problem is that I can't clear interval outside a progress function and I'm not sure what I'm doing wrong here or I miss something? Thanks in advance for any help and advices.
Your problems are caused by your timer references being lost on re-renders, and the setInterval call-back referencing out-of-date versions of setCountInterval.
To get it fully working, I would suggest adding a state variable to track whether it is started or not and a useEffect to handle the setting and clearing of setInterval.
const Example = ({ title }) => {
const [count, setCount] = useState(0);
const [countInterval, setCountInterval] = useState(0);
const [started, setStarted] = useState(false);
const handleCount = () => {
setCount(count + 1);
console.log(count);
};
useEffect(() => {
let intervalID;
if (started) {
intervalID = setInterval(() => {
setCountInterval(countInterval => countInterval + 1);
console.log(countInterval);
if (countInterval > 100) {
setCountInterval(0);
setStarted(false);
}
}, 100);
} else {
clearInterval(intervalID);
}
return () => clearInterval(intervalID);
}, [started, countInterval]);
return (
<div>
<p>{title}</p>
<p>You clicked {count} times</p>
<p>setInterval count {countInterval} times</p>
<button onClick={handleCount}>Click me</button>
<button onClick={() => setStarted(true)}>Run interval</button>
<button onClick={() => setStarted(false)}>Stop interval</button>
</div>
);
};
Working sandbox here: https://codesandbox.io/s/elegant-leaf-h03mi
countInterval is a local variable, that contains a primitive value. By the nature of JavaScript, there is no way for setState to mutate that variable in any way. Instead, it reexecutes the whole Example function, and then useState will return the new updated value. That's why you can't access the updated state, until the component rerenders.
Your problem can trivially be solved by moving the condition into the state update callback:
setCountInterval(countInterval => countInterval > 100 ? 0 : countInterval + 1)
Because of the rerendering, you can't use local variables, so intervalId will be recreated at every rerender (and it's value gets lost). Use useRef to use values across rerenders.
const interval = useRef(undefined);
const [count, setCount] = useState(0);
function stop() {
if(!interval.current) return;
clearInterval(interval.current);
interval.current = null;
}
function start() {
if(!interval.current) interval.current = setInterval(() => {
setCount(count => count > 100 ? 0 : count + 1);
});
}
useEffect(() => {
if(count >= 100) stop();
}, [count]);

Categories

Resources