How to update state object within setInterval? [Hooks] - javascript

I am wondering if anyone has experience merging a state object within a setInterval() function. After trying a few things, I ended up with the solution below, but would appreciate any additional input / tips on how to do this.
Some context: My codebase started growing, and now I have multiple state variables. I am trying to group the ones that are related into a single object to have more control over the number of renders that occur. One of those state variables is updated within a setInterval() function.
I originally had a single state:
const [seconds, setSeconds] = useState(10)
const start = () => {
interval = setInterval(() => {
setSeconds((seconds) => seconds - 1000);
}, 1000);
}
But I am trying to implement something like:
const [timer, setTimer] = useState({ seconds: 10, status: 'initial', count: 0 })
And I need to update the 'seconds' property of this object. First I attempted something like ... setTimer({ ...timer, seconds: timer.seconds - 1000 }); ... which left the interval running, but the 'seconds' were never updated from the subtraction.
Eventually, I implemented the following, which seems to do the trick so far:
const start = () => {
interval = setInterval(() => {
setTimer((timer) => (timer = { ...timer, seconds: timer.seconds - 1000 }));
}, 1000);
}

For example, you can use Immer, like in this article. And you can set your state more easily.

You need to do like this:
const start = () => {
interval = setInterval(() => {
setTimer((timer) => ({ ...timer, seconds: timer.seconds - 1000 }));
}, 1000);
}
Or better would be this:
const updatedTimer = {...timer,seconds:timer.seconds - 1000};
const start = () => {
interval = setInterval(() => {
setTimer(updatedTimer);
}, 1000);

Related

React, can't access updated value of state variable inside function passed to setInterval() in useEffect()

I am building a simple clock app with React. Currently the countDown() function works, but I would like the user to be able to stop/start the clock by pressing a button. I have a state boolean called paused that is inverted when the user clicks a button. The trouble is that after the value of paused is inverted, the reference to paused inside the countDown() function passed to setInterval() seems to be accessing the default value of paused, instead of the updated value.
function Clock(){
const [sec, setSecs] = useState(sessionLength * 60);
const [paused, setPaused] = useState(false);
const playPause = () => {
setPaused(paused => !paused);
};
const countDown = () => {
if(!paused){
setSecs(sec => sec - 1)
}
}
useEffect(() => {
const interval = setInterval(() => {
countDown();
}, 1000);
return () => {
clearInterval(interval);
};
}, []);
I'm assuming it has something to do with the asynchronous nature of calls to setState() in React, and/or the nature of scoping/context when using regular expressions. However I haven't been able to determine what is going on by reading documentation related to these concepts.
I can think of some workarounds that would allow my app to function as desired. However I want to understand what is wrong with my current approach. I would appreciate any light anyone can shed on this!
In your code, the useEffect is called only once when mounting the component.
The countdown function registered inside will have its initial value at the time when the useEffect/setInterval is called. So paused will only have the value when you initially mount the component. Because you are not calling countDown directly or updating its value inside your useEffect, it is not updated.
I think you could solve this issue like this:
interval.current = useRef(null);
const countDown = () => {
if(!paused){
setSecs(sec => sec - 1)
}
}
useEffect(() => {
clearInterval(interval.current);
interval.current = setInterval(countDown, 1000);
return () => {
clearInterval(interval.current);
};
}, [paused]);
your useEffect is dependent on the value of paused as it needs to create a new interval (with a different countdown function). This will trigger the useEffect not only on mount but every time paused changes. So one solution would be to clear the interval and start a new one with a different callback function.
Edit: You could actually improve it as you only want the interval to be running if the countDown function actually does something so this should work too:
useEffect(() => {
clearInterval(interval.current);
if(!paused) {
interval.current = setInterval(countDown, 1000);
}
return () => {
clearInterval(interval.current);
};
}, [paused]);

react better solution for custom hooks when using setInterval and stale closures

const useCounterInterval = (
startAt = 0,
countingStep = 1,
countingTime = 1000
) => {
const [counter, setCounter] = useState(startAt);
const [timerId, setTimerId] = useState(null);
const scheduleCounting = useCallback(() => {
if (timerId !== null) return;
setTimerId(
setInterval(() => {
setCounter(counter => counter + countingStep);
}, countingTime)
);
}, [timerId, countingStep, countingTime]);
const clearSchedule = useCallback(() => {
clearInterval(timerId);
setTimerId(null);
}, [timerId]);
const stopCounting = useCallback(() => {
clearSchedule();
}, [clearSchedule]);
const resetCounting = useCallback(() => {
clearSchedule();
setCounter(0);
}, [clearSchedule]);
useEffect(() => {
scheduleCounting();
// scheduleCounting();
// clearSchedule();
}, []);
return [counter, scheduleCounting, stopCounting, resetCounting];
};
Tried real hard to insert running code snippets for this code but didn't work as I thought sorry for that.
I created this custom hook to get a grasp of what hooks are.
It works but there are logical issues.
cannot 'clearInterval()' before second render (stopCounting())
Logically, can set multiple counting schedules by code (scheduleCounting())
maybe hook design is wrong at first place?
For the first issue, I cannot clear the very first scheduled timer set in motion by useEffect() before the second render where timerId is set with new value. Because obviously, stopCounting() have closure to timerId where the value is null.
And when the timing is right, I can notice by clicking on a button (depends on which event stopCounter() is bounded). I don't have a clue on how to solve this issue for now. I can just remove scheduleCounting() from useEffect() but that's not an option I wanna know if there's a way to solve this.
Second, I can set multiple counting schedule by calling scheduleCounting() in useEffect(). Come to think of it, I might call scheduleCounting() multiple times by clicking on a button really fast but seems like I can't, unlike calling stopCounting() before second render. But the logic is that I can do that right?
So, the problem here was that scheduleCounting() had closure to the same timerId value which is null
const scheduleCounting = useCallback(() => {
if (timerId !== null) return; // bypassing this line of code
The solution here was to make use of useRef(). Instead of checking if timerId is null whose value may vary depending on the creation time of functions referencing it. I can simply
const schedulerState = useRef(false)
if (schedulerState.current === true) return;
It made sure every scheduleCounting() was referencing the same latest value at any time. But using a flag(ref, state) make code less readable. I wanna keep myself away from using flags. I wonder maybe there's another reactive way of dealing with this problem?
I don't know where I'm getting wrong. Can't stop thinking I might go down the wrong road if I don't get it right. Need some guidance.
From design point of view, I'd not keep timerId in state. Instead I'd have state isCounting:
const useCounterInterval = (
startAt = 0,
countingStep = 1,
countingTime = 1000
) => {
const [isCounting, setIsCounting] = useState(false);
const [currentTick, setTick] = useState(0);
const startCounting = useCallback(() => { setIsCounting(true); }, []);
const stopCounting = useCallback(() => { setIsCounting(false); }, []);
const resetCounting = useCallback(() => { setTick(0); }, []);
useEffect(() => {
resetCounting();
if (isCounting) {
const timerId = setInterval(() => {
setTick(i => i + 1);
}, countingTime);
return () => { clearInterval(timerId); };
}
}, [isCounting, resetCounting, countingTime, startAt, countingStep]);
return [
currentTick * countingStep + startAt,
startCounting,
stopCounting,
resetCounting,
isCounting
];
};
It's easier not to store timerId but keep it in useEffect's closure. As well as it's better to return whether counting is happening(by returning isCounting).

start timer when user click button, React native

I created a function in which when user click start button timer will start, but it's not working. Can someone tell me why it's not working? please
that's the function I created
const [time,setTime] = useState(0)
const timeout = setInterval(() => {
if (time !== 60) {
setTime(prevState => prevState + 1);
}
}, 1000);
console.log(timeout);
return () => {
if (time == 60) {
clearTimeout(timeout);
}
};```
You could declare the Timer State as 60 instead of 0 and
const [state,updateState] = useState({timer:60})
then call this in updateState: ({timer: timer - 1})
To answer your question:
why is my code not working?
Your state timer starts out being 0 and will therefore never reach inside the if statement.
As Matt U pointed out you most likely want to use setInterval since it runs the function you pass at every X milliseconds (1000 in your case) until you stop it.
See the following for more information regarding that:
setTimeout: https://www.w3schools.com/jsref/met_win_settimeout.asp
setInterval: https://www.w3schools.com/jsref/met_win_setinterval.asp
What yesIamFaded answered should do the job in your use case, though it would be better to make use of updateState's argument prevState (or whatever you want to call it). updateState will receive the previous value and use that to compute a new value.
const [state, updateState] = useState({ timer: 60 })
const interval = setInterval(() => {
if (state.timer > 0) {
updateState(prevState => { timer: prevState.timer - 1 });
}
}, 1000);
You can read more about functional updates here:
https://reactjs.org/docs/hooks-reference.html#functional-updates
And lastly, you should clear the timeout and/or interval once you don't need it anymore using either clearTimeout() or clearInterval().
See the following for more information here:
clearTimeout: https://www.w3schools.com/jsref/met_win_cleartimeout.asp
clearInterval: https://www.w3schools.com/jsref/met_win_clearinterval.asp
P.S.
If your timer state isn't coupled with any other state I wouldn't put it into an object. Instead I would do the following:
const [timer, setTimer] = useState(60)
const interval = setInterval(() => {
if (timer > 0) {
setTimer(prevTimer => prevTimer - 1 );
}
}, 1000);
That way you won't have an unnecessary object.

How to trigger a function at regular intervals of time using hooks and when certain criteria is met I want to clear the time interval?

I have a react component that performs a certain task at regular intervals of time after mounting. But I want to clear the interval once after a criterion is met. How can I achieve that?
My code
const [totalTime, setTotalTime] = React.useState(10000);
const foo = () => {
console.log("Here");
};
React.useEffect(() => {
const secondInterval = setInterval(() => {
if (totalTime > 0) setTotalTime(totalTime - 1000);
}, 1000);
return () => clearInterval(secondInterval);
});
React.useEffect(() => {
let originalInterval;
if (totalTime > 0)
originalInterval = setInterval(() => {
foo();
console.log(totalTime);
}, 5000);
return () => clearInterval(originalInterval);
}, []);
When I watch the console even after 10000ms It is still logging Here and also totalTime is always being 10000ms. I am not able to figure out what exactly is happening.
You may need to pass the older state as an argument to the setTotalTime updater function. You also may need to pass (another) state variable as a dependency to the useEffect hook so that the function is executed every time the state variable changes
React.useEffect(() => {
const secondInterval = setInterval(() => {
if (totalTime > 0) setTotalTime(totalTime => totalTime - 1000);
}, 1000);
return () => clearInterval(secondInterval);
}, [...]);
Depends on your criteria, and what you call a criteria, but you could just use another state and then useEffect on that another state:
const [criteriaIsMet,setCriteriaIsMet] = useState(false);
useEffect(() => { if(criteriaIsMet) {clearInterval(...)} },[criteriaIsMet])
And then somewhere when you do your actual "criteria logic" you just go ahead and do setCriteriaIsMet(true)
And how would you know Id of interval in above useEffect - again you could just create a special state that will store that id, and you could set it in your original useEffect
As for why your current code is not clearing the interval is because when you use useEffect with empty array as second argument, and return function in first function argument - that will be execute on component unmounting
And these are just one of numerous options you have actually :)

Why useEffect cleanup logging every time?

I'm implementing count down
and use useRef hook to using it when clean setTimeout when the user navigates to the next screen to avoid cancel all subscription warning and it's work!
But I have something weird when count - 1 i can see "hey" in the console! although not cleaning the setTimeOut!!
I don't want to clean it in this case but why should loggin every time count changes!
code snippet
const [seconds, setSeconds] = useState(40);
const countRef = useRef(seconds);
useEffect(() => {
if (seconds > 0) {
countRef.current = setTimeout(() => {
setSeconds(seconds - 1);
}, 1000);
} else {
setSeconds(0);
}
return () => {
console.log('hey'); // every count down it's appeared
clearTimeout(countRef.current);
};
}, [seconds]);
You see "hey" because you're using seconds as a dependency. So every time seconds changes, the effect must run again leading to the effect's destroy function (the function you returned from the effect) to be invoked.
Instead of having seconds as a dependency, you should instead have setSeconds.
const [seconds, setSeconds] = React.useState(10);
useEffect(() => {
let didUnsub = false;
const id = setInterval(() => {
setSeconds((prev) => {
// while the count is greater than 0, continue to countdown
if (prev > 0) {
return prev - 1;
}
// once count eq 0, stop counting down
clearInterval(id);
didUnsub = true;
return 0;
});
}, 1000);
return () => {
console.log("unmounting");
// if the count didn't unsubscribe by reaching 0, clear the interval
if (!didUnsub) {
console.log("unsubscribing");
clearInterval(id);
}
};
}, [setSeconds]);
If you look at the example below, you'll see that the effect is only run once, when the component is mounted. If you were to cause the component to dismount, the destroy function would be invoked. This is because the setState is a dispatch function and doesn't change between renders, therefor it doesn't cause the effect to continuously be called.
In the example you can click the button to toggle between mounting and dismounting the counter. When you dismount it notice that it logs in the console.
Example: https://codesandbox.io/s/gallant-silence-ui0pv?file=/src/Countdown.js

Categories