How to Create a Simple React Countdown Timer
December 08, 2019
A Few Words in Front
It’s been 3 months since my last post. Sorry for keeping delaying the post because I was quite busy with my studies and works and did not have much spare time to write a post. (Yeah, laziness, Lol)
Today I am going to share one interesting and useful small front-end feature implementation in React, a simple count down timer.
Update on 9 Dec 2019
Thanks to @Laurent, he suggested me to use setTimeout()
to replace setInterval()
in the final solution, which I think it’s a better idea! setTimeout()
only runs once, hence, we don’t have to clear the setInterval()
in every useEffect()
change. Wonderful!
Solution
The correct implementation can be found at simple-react-countdown-timer if you wish to implement quickly without reading through my explanation.
Explanation
First attempt, in an intuitive way
Initially, we utilise useState
react hook to create a new state variable counter
in the functional component. counter
holds the number of seconds the counter should start with. Then a native JavaScript function, setInterval
is called to trigger setCounter(counter - 1)
for every 1000ms. Intuitively, it represents the number decreases by 1 every 1 second.
function App() {
const [counter, setCounter] = React.useState(60);
// First Attempts
setInterval(() => setCounter(counter - 1), 1000);
return (
<div className="App">
<div>Countdown: {counter}</div>
</div>
);
}
However, it works, in a terrible way. You can clearly notice that Initially the countdown works fine but then start to gradually accelerate.
That is because every time when setCounter
is triggered, the App
component get re-rendered. As the component is re-rendered, the App()
function is executed again, therefore, the setInterval()
function triggers again. Then there are 2 setInterval()
running at the same time and both triggering setCounter()
, which again, creates more setInterval()
.
Therefore, more and more setInterval()
are created and the counter is deducted for more and more times, finally resulting in accelerating decrement.
Second attempt, utilizing useEffect hook
Ok, maybe we can solve the problem by just trigger the setInterval()
once in the life cycle of a component by using useEffect()
react hook.
function App() {
const [counter, setCounter] = React.useState(60);
// Second Attempts
React.useEffect(() => {
counter > 0 && setInterval(() => setCounter(counter - 1), 1000);
}, []);
return (
<div className="App">
<div>Countdown: {counter}</div>
</div>
);
}
useEffect
is a react hook which accepts parameters including a function to be triggered at a specific point of time and an array of dependencies.
- If the dependencies are not specified, the function is triggered every time any state inside of this component is updated.
- If the dependencies are specified, only when the particular dependant state is changed, the function is triggered.
- If the dependency array is empty, then the function is only triggered once when the component is initially rendered.
So in this way, surely setInterval()
can only be triggered once when the component is initially rendered.
Are we getting the correct result here?
Wrong again! The countdown mysteriously freezes after being decremented by 1. I thought setInterval()
should be running continuously? Why it is stopped? To find out what happened, let’s add a console.log()
.
React.useEffect(() => {
counter > 0 &&
setInterval(() => {
console.log(counter);
setCounter(counter - 1);
}, 1000);
}, []);
Now the console prints out:
All the numbers printed out are 60, which means the counter itself has not been decreased at all. But setCounter()
definitely has run, then why isn’t the counter
updated?
This counter
is indeed not decreased because the setCounter
hook essentially does not change the counter
within THIS function. The following illustration may make things clearer.
Because every time when the component is re-rendered, the App()
function is called again. Therefore, within the App()
scope, only in the first time, the useEffect()
is triggered and the setInterval()
is within the first time App()
scope with the property counter
always equal to 60.
In the global environment, there is only one setInterval()
instance which contiguously set the counter
to 59, causing new App()
calls always get the state counter
to be 59. That’s why the counter seems to be freezed at 59. But in fact, it is not freezed, it is being reset all the time but the value is ALWAYS 59.
Third Attempts, useEffect with cancelling interval
To overcome the issue mentioned above, we need to trigger the setInterval()
in every single App()
call with different counter
value, just as illustrated below.
To achieve that, we need to do 2 things:
- Let
setInterval()
get triggered every time when component gets re-rendered Solution: add a dependency ofcounter
inuseEffect
hook so that every time when thecounter
changes, a newsetInterval()
is called. - Clear
setInterval()
in this scope to avoid duplicated countdown Solution: add a callback function inuseEffect
hook to clear the interval in current scope so that only onesetInterval()
instance is running in the global environment at the same time.
Thus, the final solution is
function App() {
const [counter, setCounter] = React.useState(60);
// Third Attempts
React.useEffect(() => {
const timer =
counter > 0 && setInterval(() => setCounter(counter - 1), 1000);
return () => clearInterval(timer);
}, [counter]);
return (
<div className="App">
<div>Countdown: {counter}</div>
</div>
);
}
And it looks correct!
Thank you for reading!!
Featured image is credited to Anton Makarenko from Pexels