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.
Related
Take the below code as an example.
import { useEffect, useState } from "react";
export default function Stopwatch(){
const [stopwatch, setStopwatch] = useState(0);
function updateStopwatch(){
console.log("Updating stopwatch from ", stopwatch, " to ", stopwatch+1);
setStopwatch(stopwatch + 1);
}
useEffect(() => {
const interval = setInterval(updateStopwatch, 1000);
return () => clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div>
<h1>{stopwatch}</h1>
</div>
);
}
In this code, the function updateStopwatch will be invoked periodically without depending on any re-rendering of the component ( Although its invocation will cause a re-render ) and each time it will be invoked, it needs to retrieve the current up-to-date value of the state variable stopwatch, so it can update it accordingly. So that the stop watch will keep counting up.
However, what happens is that it only gets the value of stopwatch at the moment the function is declared. It's not really subscribed to a state variable but more like it just reads a constant once. And as a result the count show 1 and is stuck there.
So how can I make such a function get an up-to-date value of a state variable whenever it's invoked?
In your setState method, you can retrieve a prevState argument, which is always the right value, here is the way you could do your function based on your example :
function updateStopwatch(){
setStopwatch((prevState) => prevState + 1);
}
This is an issue of a stale closure over the initial stopwatch state value. Use a functional state update to correctly update from the previous state value instead of whatever value is closed over in callback scope at the time of execution. Move updateStopwatch declaration into the useEffect hook to remove it as an external dependency.
Example:
export default function Stopwatch(){
const [stopwatch, setStopwatch] = useState(0);
useEffect(() => {
function updateStopwatch(){
setStopwatch(stopwatch => stopwatch + 1);
}
const interval = setInterval(updateStopwatch, 1000);
return () => clearInterval(interval);
}, []);
return (
<div>
<h1>{stopwatch}</h1>
</div>
);
}
I am trying to run a function continuously until a condition is met, but in this test case, until the off button is pressed. My first issue is that the function does not stop when i press the off button.
let intervalId
function on(){
intervalId = window.setInterval(function(){
setnum(num=>num+1)
//setnum(num + 1)
//Line 11 results in the number going up once and if i keep pressing the button it goes up by one but flashes between numbers more and more frantically every press. The off button has no effect.
//updateUserMoney()
}, 400);
}
function off(){
clearInterval(intervalId)
}
return (
<>
{num}
<button onClick={()=>on()}>On</button>
<button onClick={()=>off()}>Off</button>
</>
The second issue is that the function I want to run in the interval (that setnum is standing in for) is actually
function updateUserMoney(){
batch(()=>{
dispatch(updateUser({money: user.money + 1, energy: user.energy - 1}))
dispatch(incrementTime(1))
})
}
Here, the incrementTime function works as intended and continues to increment, but the update user function only fires once.
I think it has the same problem that line 11 has where setnum(num + 1) doesn't work but setnum(num => num + 1) does. I haven't used the second syntax much and don't understand why it's different can anybody tell me?
Here's the full code
import { useState } from "react";
import { batch, useDispatch, useSelector } from "react-redux";
import { incrementTime, updateUser } from "../actions";
const GeneralActions = () => {
const dispatch = useDispatch()
const user = useSelector((state)=>state.user)
const [num, setnum]= useState(0)
let intervalId
function updateUserMoney(){
batch(()=>{
dispatch(updateUser({money: user.money + 1, energy: user.energy - 1}))
dispatch(incrementTime(1))
})
}
function on(){
intervalId = window.setInterval(function(){
updateuserMoney()
setnum(num=>num+1)
}, 400);
}
function off(){
clearInterval(intervalId)
}
return (
<>
<br/>
<>{num}</>
<button onClick={()=>on()}>On</button>
<button onClick={()=>off()}>Off</button>
</>
);
}
export default GeneralActions;
Any insight is appreciated. Thank you!
Every time you set a new state value in React, your component will rerender. When your GeneralActions component rerenders, your entire function runs again:
const GeneralActions = () => {
// code in here runs each render
}
This means things such as intervalId, will be set to undefined when it runs let intervalId; again, and so on this particular render you lose the reference for whatever you may have set it to in the previous render. As a result, when you call off(), it won't be able to refer to the intervalId that you set in your previous render, and so the interval won't be cleared. If you want persistent variables (that aren't related to state), you can use the useRef() hook like so:
const GeneralActions = () => {
const intervalIdRef = useRef();
...
function on(){
clearInterval(intervalIdRef.current); // clear any currently running intervals
intervalIdRef.current = setInterval(function(){
...
}, 400);
}
function off(){
clearInterval(intervalIdRef.current);
}
}
One thing that I've added above is to clear any already created intervals when on() is executed, that way you won't queue multiple. You should also call off() when your component unmounts so that you don't try and update your state when the component no longer exists. This can be done using useEffect() with an empty dependency array:
useEffect(() => {
return () => off();
}, []);
That should sort out the issue relating to being unable to clear your timer.
Your other issue is with regards to setNum() is that you have a closure over the num variable for the setTimeout() callback. As mentioned above, every time your component re-renders, your function body is executed, so all of the variables/functions are declared again, in essence creating different "versions" of the num state each render. The problem you're facing is that when you call setInterval(function(){}), the num variable within function() {} will refer to the num version at the time the function was created/when setInterval() function was called. This means that as you update the num state, your component re-renders, and creates a new num version with the updated value, but the function that you've passed to setInterval() still refers to the old num. So setNum(num + 1) will always add 1 to the old number value. However, when you use useState(num => num + 1), the num variable that you're referring to isn't the num "version"/variable from the surrounding scope of the function you defined, but rather, it is the most up to date version of the num state, which allows you to update num correctly.
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
I'm writing an incremental-style game in React and I have this setInterval inside App.ts:
useEffect(() => {
const loop = setInterval(() => {
if (runStatus) {
setTime(time + 1);
}
}, rate);
return () => clearInterval(loop);
});
I've also got various event handlers along the lines of:
<button onClick={() => increment(counter + 1)}>Increment</button>
Unfortunately, if I click that button multiple times in a second, it will block the setInterval from updating the time state.
This state using the hooks as well as Redux stored state.
How can I, at the very least, make my setInterval tick reliably, so that I can click around the page without blocking it?
Do it like that
[] empty dependency means only execute when a component will mount.
useEffect(() => {
const loop = setInterval(() => {
if (runStatus) {
setTime(prev => prev + 1);
}
}, rate);
return () => clearInterval(loop);
}, []); <--- add that []
Notes
adding time as a dependency will create an infinite loop
you need to add runStatus or rate variable as a dependency if its dynamic state
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()))