DEV Community

Zhiyue Yi
Zhiyue Yi

Posted on

How to Create a Simple React Countdown Timer

Visit my Blog for the original post: How to Create a Simple React Countdown Timer

A Few Words in Front

Today I am going to share one interesting and useful small front-end feature implementation in React, a simple count down timer.

Solution

The correct implementation can be found at simple-react-countdown-timer if you wish to implement quickly without reading through my explanation.

import * as React from "react";
import { render } from "react-dom";

import "./styles.css";

function App() {
  const [counter, setCounter] = React.useState(60);

  // First Attempts
  // setInterval(() => setCounter(counter - 1), 1000);

  // Second Attempts
  // React.useEffect(() => {
  //   counter > 0 && setInterval(() => setCounter(counter - 1), 1000);
  // }, []);

  // Second Attempts - Inspection
  // React.useEffect(() => {
  //   counter > 0 &&
  //     setInterval(() => {
  //       console.log(counter);
  //       setCounter(counter - 1);
  //     }, 1000);
  // }, []);

  // Third Attempts
  // React.useEffect(() => {
  //   const timer =
  //     counter > 0 && setInterval(() => setCounter(counter - 1), 1000);
  //   return () => clearInterval(timer);
  // }, [counter]);

  // Suggested by Laurent
  React.useEffect(() => {
    counter > 0 && setTimeout(() => setCounter(counter - 1), 1000);
  }, [counter]);

  return (
    <div className="App">
      <div>Countdown: {counter}</div>
    </div>
  );
}

const rootElement = document.getElementById("root");
render(<App />, rootElement);

Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

However, it works, in a terrible way. You can clearly notice that Initially the countdown works fine but then start to gradually accelerate.

CountDown 1

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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?

CountDown 2

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);
}, []);
Enter fullscreen mode Exit fullscreen mode

Now the console prints out:

Countdown 3

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.

Countdown 4

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.

Countdown 5

To achieve that, we need to do 2 things:

  1. Let setInterval() get triggered every time when component gets re-rendered Solution: add a dependency of counter in useEffect hook so that every time when the counter changes, a new setInterval() is called.
  2. Clear setInterval() in this scope to avoid duplicated countdown Solution: add a callback function in useEffect hook to clear the interval in current scope so that only one setInterval() 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

And it looks correct!

Countdown 6

Thank you for reading!!

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!

Discussion (17)

Collapse
arpan_banerjee7 profile image
Arpan Banerjee

I think you just complicated the things unnecessarily, always remember this rule of thumb

  • whenever you want to update the state based on the previous state , pass a function to the setState() method, then react will make sure it will call the setState() with the latest value of the state variable.
import React from "react";
export default function App() {
  const [counter, setCounter] = React.useState(60);

  // Second Attempts
  React.useEffect(() => {
     counter>0 && setInterval(() => {
        setCounter((time)=>time-1);
      }, 1000);
  }, []);

  return (
    <div className="App">
      <div>Countdown: {counter}</div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

This solution works, but it has a problem, the countdown never stops.
To make it stop at 0, we will make use of useRef hook, it will store the reference to the setInterval, whihc we can clear when the timer is 0.The complete solution would look like this.

import React from "react";
export default function App() {
  const [timer, setTimer] = React.useState(10);
  const id =React.useRef(null);
  const clear=()=>{
  window.clearInterval(id.current)
}
  React.useEffect(()=>{
     id.current=window.setInterval(()=>{
      setTimer((time)=>time-1)
    },1000)
    return ()=>clear();
  },[])

  React.useEffect(()=>{
    if(timer===0){
      clear()
    }

  },[timer])


  return (
    <div className="App">

     <div>Time left : {timer} </div>

    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

codesandbox.io/s/reverent-bash-bxrnv

please let me know your thoughts on this.

Collapse
zhiyueyi profile image
Zhiyue Yi Author

Your solution is more intuitive than mine. Nice one!

Collapse
arpan_banerjee7 profile image
Arpan Banerjee

Thank you!

Collapse
ankit_choudhary profile image
ankit kumar • Edited on

Hi Arpan , i think if you pass Timer as dependency in useEffect, that will do the job simply.
you dont need to use useRef and 2nd useEffect.
let me know your thoughts.

React.useEffect(() => {
const TimerInt = timer >0 && setInterval(() => {
setCounter((time)=>time-1);
}, 1000);
return () => {
clearInterval(TimerInt)
}
}, [timer]);

Collapse
hussamkhatib profile image
mohammed hussam

hey this is a very nice implementation , but consider i want the timer to completely stop at 0 , and then we want the countdown again , if we change the value of timer then it won't do anything.
Any idea how we can overcome this

Collapse
seanmclem profile image
Seanmclem

I had good success creating a timer by storing the current datetime when starting the timer, and then on every interval getting the new current datetime again and doing the math to find the difference. If any computation takes longer than it should - it doesn't matter. The date time is always valid

Collapse
zhiyueyi profile image
Zhiyue Yi Author

Hey Sean! Interesting thoughts! I tried your approach, and it also works!

Collapse
seanmclem profile image
Seanmclem

Thanks! That's awesome

Collapse
rmar72 profile image
Ruben Solorio • Edited on

Nice work! Learned good stuff and folks' comments! <3 Thanks!

Will point out that for more comprehensive timers a major problem with setIntervals or setTimeouts is that they're unreliable when switching between tabs/phone locks. Usually a 1000ms lag but can be arbitrary.
Solutions I've seen so far to the inaccuracy issue will be based on Date objects, Performance interface, requestAnimationFrame etc

Collapse
ferhatavdic profile image
FerhatAvdic

You sir are amazing! You saved me so much time at work with this valuable post!! I needed to build a countdown from a certain date in days hours minutes and seconds.

Collapse
zhiyueyi profile image
Zhiyue Yi Author

Thank you! Glad it helps!

Collapse
ankit_choudhary profile image
ankit kumar

hi @zhiyueyi , let me know your thoughts about this.

React.useEffect(() => {
const TimerInt = timer >0 && setInterval(() => {
setCounter((time)=>time-1);
}, 1000);
return () => {
clearInterval(TimerInt)
}
}, [timer]);

Collapse
elramus profile image
Luke Ramus • Edited on

Cool, thank you! Only flaw is that if the user switches to a different tab, the timer pauses. Any thoughts on how to get around that, or is that just a limitation of how JavaScript runs in the browser?

Collapse
trangcongthanh profile image
Thành Trang • Edited on

Whats limitation?

If your component will unmount, you should lift your count state up (or make it global) to store the current count. Then when remount, init your count state with the previous count. It'll start counting at the previous position.

If you component will not unmount, just clear your current timeout when the tab lost focus. It'll pause. When it get focused again, set your timeout back.

Collapse
sbyeol3 profile image
SaetByeol Ahn

Thanks 😂!!! It's Helpful to me <3

Collapse
belclei profile image
Belclei

Hi!
If instead "setInterval" you use "setTimeout", your timer works perfectly in a simple code like your first try.

Collapse
sonhip profile image
Son_Tran_Van

realy like your post, it's really useful, thanks!!!!!!