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
Related
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".
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 :)
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
I am a bit confused as to why this component does not work as expected:
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // This effect depends on the `count` state
}, 1000);
return () => clearInterval(id);
}, []); // 🔴 Bug: `count` is not specified as a dependency
return <h1>{count}</h1>;
}
but rewriting as below works:
function Counter() {
const [count, setCount] = useState(0);
let c = count;
useEffect(() => {
const id = setInterval(() => {
setCount(c++);
}, 1000);
return () => clearInterval(id);
}, []);
return <h1>{count}</h1>;
}
React documentation says:
The problem is that inside the setInterval callback, the value of count does not change, because we’ve created a closure with the value of count set to 0 as it was when the effect callback ran. Every second, this callback then calls setCount(0 + 1), so the count never goes above 1.
But the explanation does not make sense. So why the first code does not update count correctly but the second does?
(Also declaring as let [count, setCount] = useState(0) then using setCount(count++) works fine too).
Why it looks like it doesn't work?
There are a couple hints that can help understand what's going on.
count is const, so it'll never change in its scope. It's confusing because it looks like it's changing when calling setCount, but it never changes, the component is just called again and a new count variable is created.
When count is used in a callback, the closure captures the variable and count stays available even though the component function is finished executing. Again, it's confusing with useEffect because it looks like the callbacks are created each render cycle, capturing the latest count value, but that's not what's happening.
For clarity, let's add a suffix to variables each time they're created and see what's happening.
At mount time
function Counter() {
const [count_0, setCount_0] = useState(0);
useEffect(
// This is defined and will be called after the component is mounted.
() => {
const id_0 = setInterval(() => {
setCount_0(count_0 + 1);
}, 1000);
return () => clearInterval(id_0);
},
[]);
return <h1>{count_0}</h1>;
}
After one second
function Counter() {
const [count_1, setCount_1] = useState(0);
useEffect(
// completely ignored by useEffect since it's a mount
// effect, not an update.
() => {
const id_0 = setInterval(() => {
// setInterval still has the old callback in
// memory, so it's like it was still using
// count_0 even though we've created new variables and callbacks.
setCount_0(count_0 + 1);
}, 1000);
return () => clearInterval(id_0);
},
[]);
return <h1>{count_1}</h1>;
}
Why does it work with let c?
let makes it possible to reassign to c, which means that when it is captured by our useEffect and setInterval closures, it can still be used as if it existed, but it is still the first one defined.
At mount time
function Counter() {
const [count_0, setCount_0] = useState(0);
let c_0 = count_0;
// c_0 is captured once here
useEffect(
// Defined each render, only the first callback
// defined is kept and called once.
() => {
const id_0 = setInterval(
// Defined once, called each second.
() => setCount_0(c_0++),
1000
);
return () => clearInterval(id_0);
},
[]
);
return <h1>{count_0}</h1>;
}
After one second
function Counter() {
const [count_1, setCount_1] = useState(0);
let c_1 = count_1;
// even if c_1 was used in the new callback passed
// to useEffect, the whole callback is ignored.
useEffect(
// Defined again, but ignored completely by useEffect.
// In memory, this is the callback that useEffect has:
() => {
const id_0 = setInterval(
// In memory, c_0 is still used and reassign a new value.
() => setCount_0(c_0++),
1000
);
return () => clearInterval(id_0);
},
[]
);
return <h1>{count_1}</h1>;
}
Best practice with hooks
Since it's easy to get confused with all the callbacks and timing, and to avoid any unexpected side-effects, it's best to use the functional updater state setter argument.
// ❌ Avoid using the captured count.
setCount(count + 1)
// ✅ Use the latest state with the updater function.
setCount(currCount => currCount + 1)
In the code:
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
// I chose a different name to make it clear that we're
// not using the `count` variable.
const id = setInterval(() => setCount(currCount => currCount + 1), 1000);
return () => clearInterval(id);
}, []);
return <h1>{count}</h1>;
}
There's a lot more going on, and a lot more explanation of the language needed to best explain exactly how it works and why it works like this, though I kept it focused on your examples to keep it simple.
More on closures.
useRef makes it easy
function Counter() {
const countRef = useRef(0);
useEffect(() => {
const id = setInterval(() => {
countRef.current++;
}, 1000);
return () => clearInterval(id);
}, []);
return <h1>{countRef.current}</h1>;
}
I have an array with 3 elements. I want to loop through it such that after 3 seconds the next element of array is rendered in place of the previous one and if last element is rendered then it should restart again. It's like after 3 seconds I want to display the next element in place of previous one as like looping through the array but showing one element at a time. I have tried the following code for it.
import React, { useEffect, useState } from 'react';
import './text.css';
const Text = () => {
var work = ["fighting", "learning", "qwerty"];
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
if(seconds===2) setSeconds(seconds => seconds = 0);
else setSeconds(seconds => seconds + 1);
console.log(seconds);
}, 3000);
}, []);
return (
<div>
<h1>{work[seconds]}</h1>
</div>
);
}
export default Text;
It is successfully rendering elements one after another but can't render anything after the last element. Also on console the value of seconds is always showing 0.
The issue is that the initial state value is enclosed in the scope of the callback and never updates the state value in the outer scope, but defining the callback outside allows the state to update. Also, increment an index value instead and take the modulus of the array length to always get a valid index.
import React, { useEffect, useState } from "react";
const work = ["fighting", "learning", "qwerty"];
export default function App() {
const [index, setIndex] = useState(0);
useEffect(() => {
const tick = () => setIndex(i => i + 1);
const id = setInterval(tick, 3000);
return () => clearInterval(id);
}, []);
return (
<div>
<h1>{work[index % work.length]}</h1>
</div>
);
}
You were not getting the updated second value inside the setInterval.
Also, clearInterval in useEffect.
Working demo
Refactored code
useEffect(() => {
const interval = setInterval(() => {
setSeconds(second => (second === 2 ? 0 : second + 1));
}, 3000);
return () => clearInterval(interval)
}, []);
Update:
The console.log inside setInterval's callback will always be 0 because the seconds inside the setInterval's callback is the value taken when the callback was registered with setInterval.
To see up-to-date values of second, use another useEffect with seconds as dependency.
useEffect(() => {
console.log("seconds", seconds);
}, [seconds]);