Tutorial: Create a timer that can be started and stopped
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 Date
s 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:
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);
intervalId.current = null;
};
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);
intervalId.current = null;
};
<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:
Member discussion