6 min read

Tutorial: Create a timer that can be started and stopped

đź’ˇ
This is a detailed tutorial that solves the Create a timer exercise.
If you haven't already, try to solve it yourself first before reading the solution!

To build our timer, it helps to split the work in a few parts:

  • First, we’ll just need to display the date: e.g. display 5 minutes
  • Then, we’ll need to add the functionality to count down and start/stop the timer
  • Lastly, we’ll need to hook the Reset button

Let’s go over these one by one.

Step 1: Initialise date to 5 minutes and display

Initialise date to 5 minutes

Looking over the Date object official documentation, we learn it has a constructor that supports passing in the date, month, year, minutes and seconds to use when initialising the object, so let's do just that.

// new Date(year, monthIndex, day, hours, minutes)

const fiveMinutes = new Date(0, 0, 0, 0, 5)

Formatting the date

The easiest way to display the date in our timer is to use Dates default getMinutes and getSeconds methods.

const fiveMinutes = new Date(0, 0, 0, 0, 5);
const minutes = fiveMinutes.getMinutes();
const seconds = fiveMinutes.getSeconds();

console.log(`${minutes}:${seconds}`)
// Outputs: "5:0"

This looks great, but it would be nice to pad the "seconds", so we always show two digits.

One way to do this is to use the padStart method of the String object ()

const paddedSeconds = seconds.toString().padStart(2, '0');
console.log(`${minutes}:${paddedSeconds}`)
## Outputs: "5:00"

For a broader discussion of formatting options, take a look at https://www.freecodecamp.org/news/how-to-format-dates-in-javascript.

Putting everything together

Now that it's clear how to initialise the date and how to format it, let's add everything to the React app.

We'll create a new state variable for the timer value - timeRemaining and initialise it to 5 minutes. Then, we'll display the value in the UI.

import { useState } from 'react';

const formatDate = (dateObject) => {
  const minutes = dateObject.getMinutes();
  const seconds = dateObject.getSeconds();
  const paddedSeconds = seconds.toString().padStart(2, '0');
  return `${minutes}:${paddedSeconds}`;
};

export default function CountdownTimer() {
  const fiveMinutes = new Date(0, 0, 0, 0, 5);
  const [timeRemaining, setTimeRemaining] = useState(fiveMinutes);
  return (
    <div>
      <h1>{formatDate(timeRemaining)}</h1>
      <div>
        <button>Start</button>
        <button>Stop</button>
        <button>Reset</button>
      </div>
    </div>
  );
}

Step 2: Starting and stopping the countdown

Overall approach

To create a countdown timer, we can subtract 1 second from the initial 5 minutes, on every second.

Since this is an action to repeat at a given interval, we can use the setInterval.

Subtracting seconds

The easiest way to subtract a second from the date is to get its value in milliseconds and then subtract 1000 (1 second = 1000 milliseconds).

const fiveMinutes = new Date(0, 0, 0, 0, 5);
const timeRemaining = new Date(fiveMinutes.getTime() - 1000);

There are two things happening here worth mentioning:

  • we're using Date.getTime() to get the date value in milliseconds
  • we're using the Date constructor that accepts a milliseconds value to create the new Date - new Date(value)

Counting down from five minutes using `setInterval`

Let's first get this to work outside of the React context - just by running it in a browser console:

let timeRemaining = new Date(0, 0, 0, 0, 5);
const logTime = () => console.log(`${timeRemaining.getMinutes()}:${timeRemaining.getSeconds().toString().padStart(2, '0')}`)

logTime()
// 5:00


const timerInterval = setInterval(() => {
    timeRemaining = new Date(timeRemaining.getTime() - 1000); 
    logTime(); 
}, 1000);

Note we're saving the return value of setInterval in a variable - this is useful to clear the interval once we're done!

When a new interval is created, it gets a unique ID. The interval will run forever, until its id is "cleared" using clearInterval.

console.log(timerInterval)
// will output a number representing unique interval id
clearInterval(timerInterval)
// this will stop the interval

Step 3: Adding the `setInterval` to React

Overall approach

We want the timer to start the countdown when the user clicks "Start". Also, when he clicks "Stop", we want to clear it. So let's do that!

Storing the interval id

We will need to store the interval Id so we can later reference it.

One way to do this is just create a new state variable and save it there. This would work, however the component would rerender when the id changes, even if nothing changed in the UI.

  // store interval id in state
  const [intervalId, setIntervalId] = useState<number | null>(null);

  const handleClickStart = () => {
    const intervalId = window.setInterval(() => {
        // ...
    }, 1000);

    // update the state variable when we have a new id
    // would cause a rerender
    setIntervalId(intervalId);
  };

  const handleClickStop = () => {
    // If the timer is already stopped, do nothing
    if (intervalId === null) {
      return;
    }

    clearInterval(intervalId);
    // update the state variable to null once we cleared the interval
    // would cause a rerender
    setIntervalId(null);
  };

This works and it is rather readable, but it’s worth mentioning another approach - using a ref to store the interval id. This way, we can still keep a reference to the id of the currently running timer, but we no longer cause unnecessary renders.

  // store interval id as ref
  const intervalId = useRef<number | null>(null);

  const handleClickStart = () => {
    // update current value of the ref
    // does not trigger a rerender
    intervalId.current = window.setInterval(() => {
        // ...
    }, 1000);
  };

  const handleClickStop = () => {
    // If the timer is already stopped, do nothing
    if (intervalId.current === null) {
      return;
    }

    clearInterval(intervalId.current);
    // clear current value of the ref
    // does not trigger a rerender
    intervalId.current = null;
  };

Clearing the interval on unmount

So far, we are controlling the interval based on user actions: clicking the “Start” or “Stop” buttons will cause the timer to start or stop.

However, there’s anther case to consider: what if the component unmounts when the timer is running? If we don’t clear the timer, it would continue to run in the browser memory, even if it’s no longer used in the UI - we would have a memory leak!

To prevent this, we should clear the timer when the component unmounts:

  useEffect(() => {
    return () => {
      if (intervalId.current !== null) {
        clearInterval(intervalId.current);
      }
    };
  }, []);

Notice we used useEffect for this:

  • we passed an empty array for the dependencies list - this means this function will only run once
  • the effect returns a function - this is the convention React uses for cleanup code; this article also has a nice overview of how this works

Counting down

To count down, the first instinct would be to just start subtracting 1 second from the timeRemaining state variable inside the interval function:

  const [timeRemaining, setTimeRemaining] = useState(fiveMinutes);

  const handleClickStart = () => {
    intervalId.current = window.setInterval(() => {
      setTimeRemaining(new Date(timeRemaining.getTime() - 1000));
    }, 1000);
  };

But it doesn't work! Because the handler uses a "stale" closure, so the 1 second value is always decreased from the initial value of timeRemaining, instead of latest one.

Adding a console.log shows that the interval does run, but the value doesn't decrease:

Dk9-E97LMs.39.52

What we need to do instead use the "updater" version of setting the state - i.e. instead of setTimeRemaining(new Date(...)) we can use setTimeRemaining(prevTimeRemaining => new Date(...))

    window.setInterval(() => {
      setTimeRemaining(
        (prevTimeRemaining) => new Date(prevTimeRemaining.getTime() - 1000)
      );
    }, 1000);

The completed component

To wrap everything up, this is how the component would look like in the end:

export default function CountdownTimer() {
  const fiveMinutes = new Date(0, 0, 0, 0, 5);
  const [timeRemaining, setTimeRemaining] = useState(fiveMinutes);
  const intervalId = useRef<number | null>(null);

  const handleClickStart = () => {
    // If a timer is already running, do not start another one
    if (intervalId.current !== null) {
      return;
    }

    intervalId.current = window.setInterval(() => {
      setTimeRemaining(
        (prevTimeRemaining) => new Date(prevTimeRemaining.getTime() - 1000)
      );
    }, 1000);
  };

  const handleClickStop = () => {
    // If the timer is already stopped, do nothing
    if (intervalId.current === null) {
      return;
    }

    clearInterval(intervalId.current);
    intervalId.current = null;
  };

  const handleReset = () => {
    if (intervalId.current !== null) {
      clearInterval(intervalId.current);
    }
    setTimeRemaining(fiveMinutes);
  };

  useEffect(() => {
    return () => {
      if (intervalId.current !== null) {
        clearInterval(intervalId.current);
      }
    };
  }, []);

  return (
    <div style={{ textAlign: "center" }}>
      <h1>{formatDate(timeRemaining)}</h1>
      <div>
        <button onClick={handleClickStart}>Start</button>
        <button onClick={handleClickStop}>Stop</button>
        <button onClick={handleReset}>Reset</button>
      </div>
    </div>
  );
}

Notice we added one more check to the handleClickStart method, to prevent the user from starting multiple intervals if he clicks “Start” multiple times.

Step 4: Resetting the countdown

Last but not least, to reset the timer there are two things we need to do:

  • Reset the time remaining to 5 minutes again
  • Stop any running timers

This is how it would look:

const handleReset = () => {
  if (intervalId.current !== null) {
    clearInterval(intervalId.current);
  }
  setTimeRemaining(fiveMinutes);
};

<button onClick={handleReset}>Reset</button>

That's it!

Conclusion

You can check out the final working solution for this article on the Github solution branch.

How did you find this exercise? Do you have any questions? Let me know in the comment below!

If you would like to dive deeper into working with React and setInterval, I can also recommend these two articles: