React 5 second timer crashes - javascript

I'm trying to understand what my mistake is. I'm using setInterval within useEffect with [] dependency that updates the ms (milliseconds) state by adding 1 to it every 10 milliseconds. I have a time() function that is responsible for updating ms and secs state and also for stopping and displaying the timer. Once the timer reaches 5 seconds, done state is set to true, the interval is cleared and the timer should stop. But instead it crashes with this error: "Too many re-renders. React limits the number of renders to prevent an infinite loop". Why does this happen and how do I fix it? Here's the code link https://codepen.io/Montinyek/pen/zYLzBZP?editors=1111
function App() {
const [secs, setSecs] = React.useState(0);
const [ms, setMs] = React.useState(0);
const [done, setDone] = React.useState(false)
let id = React.useRef()
React.useEffect(() => {
id.current = setInterval(() => {
if (!done) {
setMs(prev => prev += 1)
}
}, 10);
return () => clearInterval(id.current);
}, [])
function time() {
if (ms === 100) {
setMs(0)
setSecs(prev => prev += 1)
}
if (secs === 5) {
clearInterval(id.current)
setDone(true)
}
let formattedSecs = secs < 10 ? "0" + secs : secs;
let formattedMils = ms < 10 ? "0" + ms : ms;
return `${formattedSecs} : ${formattedMils}`;
}
return <div>{time()}</div>;
}

The problem is that you are calling the function time() in render and that function is making calls to set state. Generally, you should never set state in render or you get into a loop situation when you render, the state is set (which triggers a rerender), then state is set again, then it rerenders, and so on.
Your problem isn't actually that new intervals are being created. Its actually unrelated entirely to the timer ticks in a way. The problem is that when it reaches 5 seconds, it gets into a "render loop".
Specifically what happens in your case is this:
The timer hits 5 seconds.
Render calls time()
clearInterval(id.current) is called and also setDone(true). The set operation here causes another render.
Render calls time().
Back to (3).
You need to encapsulate the logic that sets the state in the interval handler, and not make your logic intrinsically linked to render passes. However, this is one of the more complicated things in react (handling state in an interval) since you can get into all sorts of problems with recalling stale state. To understand my answer, you will need to read Dan Abramov's (a key React contributor) article about this. I have lifted the useInterval hook from his blog.
function useInterval(callback, delay) {
const savedCallback = React.useRef();
// Remember the latest callback.
React.useEffect(() => {
savedCallback.current = callback;
}, [callback]);
// Set up the interval.
React.useEffect(() => {
function tick() {
savedCallback.current();
}
if (delay !== null) {
let id = setInterval(tick, delay);
return () => clearInterval(id);
}
}, [delay]);
}
function App() {
const [secs, setSecs] = React.useState(0);
const [ms, setMs] = React.useState(0);
useInterval(() => {
if (ms >= 100) {
setMs(0)
setSecs(prev => prev + 1)
return
}
setMs(prev => prev + 1)
}, secs < 5 ? 10 : null)
function getFormattedTime() {
let formattedSecs = secs < 10 ? "0" + secs : secs;
let formattedMils = ms < 10 ? "0" + ms : ms;
return `${formattedSecs} : ${formattedMils}`;
}
return <div>{getFormattedTime()}</div>;
}
ReactDOM.render(<App />, document.getElementById("root"));
Note that the render now only calls getFormattedTime which does not touch the state.
When refactoring this I found done wasn't needed since useInterval supports conditionally stopping the interval easily by passing a variable tickrate: secs < 5 ? 10 : null. null means "stopped".

Related

Why does useState update in DOM but not in console?

I'm a little puzzled here and would appreciate if somebody could explain why useState exhibits this behaviour. I have a functional component using useState which starts a timer and renders the time correctly in real time in the the DOM. Upon stopping the timer I'd like to push the time to an array, but every attempt to do so simply pushed 0 - the initial state of the time variable.
After some debugging I noticed that if I console.log() the time inside of the interval-looped function it also continues to log 0, and not the "real" time.
Here's my code. I've cut out all of the parts irrelevent to the problem at hand.
export default function Timer() {
const [time, setTime] = useState(0);
const interval = useRef(null);
clearInterval(interval.current);
let startTime = Date.now() - time;
interval.current = setInterval(() => {
setTime(Date.now() - startTime);
console.log(time); // <-- Why does this continue to show 0?
}, 10);
return (
<div>
<span>{("0" + Math.floor((time / 60000) % 1000)).slice(-2)}:</span>
<span>{("0" + Math.floor((time / 1000) % 1000)).slice(-2)}.</span>
<span>{("00" + (time % 1000)).slice(-3, -1)}</span>
</div>
);
}
So my question is, why does the time variable return the correct time in real time inside of the DOM but not in the console? I thought it may be due to useState not being instant so to speak, but I don't get why it would just continue logging 0.
EDIT:
I fixed this issue by declaring a separate variable curTime = 0 and instead of using setTime(Date.now() - startTime), I used curTime = (Date.now() - startTime):
export default function Timer() {
const [time, setTime] = useState(0);
const interval = useRef(null);
let curTime = 0
clearInterval(interval.current);
let startTime = Date.now() - time;
interval.current = setInterval(() => {
curTime = (Date.now() - startTime);
setTime(curTime)
console.log(curTime); // <-- Now shows correct time.
}, 10);
return (
<div>
<span>{("0" + Math.floor((time / 60000) % 1000)).slice(-2)}:</span>
<span>{("0" + Math.floor((time / 1000) % 1000)).slice(-2)}.</span>
<span>{("00" + (time % 1000)).slice(-3, -1)}</span>
</div>
);
}
let startTime = Date.now() - time;
interval.current = setInterval(() => {
setTime(Date.now() - startTime);
console.log(time); // <-- Why does this continue to show 0?
}, 10);
From what I understand, you are trying to print the updated state value in the following line of setting the state. This will not work in the general case. The reason being the setter of the state is asynchronous in nature. It means, anytime you run setTime/setState, it will be called by React at that place itself but its updated value will only be visible on the next render. That is why your DOM shows an updated value (after every rerender). Always remember, your function execution is synchronous so the following line will be executed at that time itself but the updated state will be visible on the next render.
Having said that React team realized there might be cases when state update has to behave synchronously.
For that, they designed 'flushSync'. You can check more here:
https://reactjs.org/docs/react-dom.html#flushsync (This is not recommended by React team and should only be used when async updates of state don't help.)
React uses a declarative style. The value of the state will change when the component rerenders. You are logging before that happens.

Update parameters of constructor functions on-the-go in React js

What's up guys! Here's the thing. I want to build metronome. I've tried to do so with setInterval or setTimeout, but those methods are unaccurate at all. So I've found this beautiful constructor function, which can make every tick of metronome pretty accurate.
function Timer(callback, timeInterval, options) {
this.timeInterval = timeInterval;
// Add method to start timer
this.start = () => {
// Set the expected time. The moment in time we start the timer plus whatever the time interval is.
this.expected = Date.now() + this.timeInterval;
// Start the timeout and save the id in a property, so we can cancel it later
this.theTimeout = null;
if (options.immediate) {
callback();
}
this.timeout = setTimeout(this.round, this.timeInterval);
console.log('Timer Started');
}
// Add method to stop timer
this.stop = () => {
clearTimeout(this.timeout);
console.log('Timer Stopped');
}
// Round method that takes care of running the callback and adjusting the time
this.round = () => {
console.log('timeout', this.timeout);
// The drift will be the current moment in time for this round minus the expected time..
let drift = Date.now() - this.expected;
// Run error callback if drift is greater than time interval, and if the callback is provided
if (drift > this.timeInterval) {
// If error callback is provided
if (options.errorCallback) {
options.errorCallback();
}
}
callback();
// Increment expected time by time interval for every round after running the callback function.
this.expected += this.timeInterval;
console.log('Drift:', drift);
console.log('Next round time interval:', this.timeInterval - drift);
// Run timeout again and set the timeInterval of the next iteration to the original time interval minus the drift.
this.timeout = setTimeout(this.round, this.timeInterval - drift);
}
}
export default Timer;
Here's what I do in my App.js (parts of the code we need):
import pop from "../audio/pop.mp3";
import Timer from "../constants/timer";
const [play, setPlay] = useState(false);
const [bpm, setBpm] = useState(40);
const [rate, setRate] = useState();
const popSound = new Audio(pop);
const playPop = () => {
popSound.play();
};
const metronome = new Timer(playPop, rate, { immediate: true });
const handlePlay = () => {
if (play) {
metronome.stop();
} else {
metronome.start();
}
};
useEffect(() => {
setRate((60 / bpm) * 1000);
console.log(rate);
}, [bpm, rate]);
// And the button that controll things
<IconButton
onClick={() => {
handlePlay();
setPlay(!play);
}}
>
{play ? (
<Pause sx={{ fontSize: 60 }} />
) : (
<PlayArrow sx={{ fontSize: 60 }} />
)}
</IconButton>
And guess what!? I can start the metronome, and it works, but:
I can't update dynamically the rate. I mean it updates when I use slider, I console log it, so the rate itself get updated, but not inside metronome function.
When I try to stop the metronome, and when button gets pushed, I see "Timer stoped" in the console, but the function keeps on working.
Things doesn't get updated in the function. What am I missing?

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.

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

React hooks: new state value not reflecting in setInterval callback

I have a function react component that has a counter that starts from 10000 and goes to 0.
I am setting a setInterval callback using useEffect hook during component mounting. The callback then updates the counter state.
But I don't know why, the count value never decreases. Each time the callback runs count is 10000.
(I am using react & react-dom version 16.8.3)
Function component is as below:
import React, { useState, useEffect, useRef } from 'react'
const Counter = () => {
const timerID = useRef()
let [count, setCount] = useState(10000)
useEffect(() => {
timerID.current = setInterval(() => {
//count here is always 10000
if (count - 1 < 0) {
setCount(0)
} else {
setCount(count - 1)
}
}, 1)
}, [])
return <h1 className="counter">{count}</h1>
}
export default Counter
Here is the link to codesandbox: link
You need to watch for changes in count, and also clean up your useEffect():
useEffect(() => {
timerID.current = setInterval(() => {
if (count - 1 < 0) {
setCount(0)
} else {
setCount(count - 1)
}
}, 100)
return () => clearInterval(timerID.current);
}, [count])
As #Pavel mentioned, Dan Abramov explains why here.
There are 2 options:
1) Include count in the dependencies
This is not ideal, as it means a new setInterval will be created on every change of count, so you would need to clean it up on every render, example:
useEffect(() => {
timerID.current = setInterval(() => {
//count here is always 10000
if (count - 1 < 0) {
setCount(0)
} else {
setCount(count - 1)
}
}, 1)
return () => clearInterval(timerID.current) // Added this line
}, [count]) // Added count here
2) Add the count in the setInterval callback function.
This is the best approach for intervals, as it avoids, setting new ones all the time.
useEffect(() => {
timerID.current = setInterval(() => {
// count is used inside the setCount callback and has latest value
setCount(count => {
if (count - 1 < 0) { // Logic moved inside the function, so no dependencies
if (timerID.current) clearInterval(timerID.current)
return 0
}
return count - 1
})
}, 1)
return () => {
if (timerID.current) clearInterval(timerID.current) // Makes sure that the interval is cleared on change or unmount
}
}, [])
Here is the sandbox link
You are declaring effect function when component mount as you said. So in scope in that time value store inside count is equal to 10000. That means every time interval function executes it takes count value from closure (10000). It is actually pretty tough to do it correctly. Dan wrote whole blog post about it

Categories