July 26, 2026 at 04:13 PM EST
Implementing timers in React
July 26, 2026 at 04:13 PM EST
Implementing timers in React
Inspired by the GNOME app Exercise Timer, I decided to make custom timers in React given that my previous posts have been focusing on applications that leverage it. In this post, we’ll define tasks or activites as Missions, because it sounds nice and implies a sense of duty. A Mission constitutes of timed sessions (workTime) with an associated rest (restTime) for a number of sets. I also threw in a preparation time, a countdown before the mission starts.
Our first priority will be making cards that display the general overview of the mission, namely the total time it takes, the set breakdown, etc. Our second priority will be linking the custom timer that respects those times.
Our starting point is defining Mission object with the properties below:
It’s worth noting that the times are stored internally in seconds for consistency, and formatted properly to strings for readability. This will be covered shortly.
We create an array of Missions with arbitrary values. For example, the music session is done in 3 sets of 20 minutes with a 5 minute break between each. As mentioned, the preparation time acts as a countdown before engaging in the sets.
const missions = [
{
id: 1,
title: "Quick Coding",
workTime: 600,
restTime: 60,
prepTime: 120,
sets: 2,
clr: "#52c22d",
},
{
id: 2,
title: "Stretching",
workTime: 30,
restTime: 15,
prepTime: 60,
sets: 3,
clr: "#2a5ece",
},
{
id: 3,
title: "Music Session",
workTime: 1200,
restTime: 300,
prepTime: 0,
sets: 3,
clr: "#c41c13",
},
];
Next, we’ll make a simple dashboard that presents the missions turned into cards.
import MissionCard from "./MissionCard";
export default function MissionsDashboard() {
const missions = [
// ...
];
return (
<div>
<h3>Missions</h3>
<div className="mission-container grid md:grid-cols-2 sm:grid-cols-1">
{missions.map((mission) => {
return (
<MissionCard
id={mission.id}
title={mission.title}
workTime={mission.workTime}
restTime={mission.restTime}
sets={mission.sets}
clr={mission.clr}
/>
);
})}
</div>
</div>
);
}
where the MissionCard is defined as:
interface MissionProps {
id: number;
title: string;
workTime: number;
restTime: number;
sets: number;
clr: string;
}
export default function MissionCard({
id,
title,
workTime,
restTime,
sets,
clr,
}: MissionProps) {
return (
<div
style={{ borderColor: clr }}
className="border-2 p-2 m-2 rounded-md shadow-md"
>
<div className="flex justify-between">
<h4>{title}</h4>
<p>{workTime * sets} seconds</p>
</div>
<p>
{sets} sets of {workTime} seconds
</p>
</div>
);
}
The generated output is:
Right now, the times shown on the cards are in seconds, but we can convert them for readability (and decency). We’ll make a helper function formatTimer that formats seconds into a time string that shows the hour(s), minute(s) and second(s) component. We’ll walk through how to extract each component from the total number of seconds, call it totalSeconds. We proceed accordingly:
Seconds to H/M/S components
The flooredHours, flooredMinutes and remainingSeconds are the variables needed to construct the string above. We create a components array and push the hours, minutes, and seconds if their value is greater than 0. For example, if we have 3662 total seconds, we push “1 hour”, “1 minute” and “2 seconds” to the array. With pluralization taken into consideration, we add commas and conjunctions based on the number of elements in the array.
Implementation of Seconds to H/M/S components
// Skipping a bunch of if statements with a components array
export default function formatMissionTime(totalSeconds: number) {
// Extract the hour component
const rawHours = totalSeconds / 3600
const flooredHours = Math.floor(rawHours)
// Remove the hours from the seconds and get the minutes
const secondsWithoutHours = totalSeconds % 3600
const rawMinutes = secondsWithoutHours / 60
const flooredMinutes = Math.floor(rawMinutes)
// Extract the second component
const remainingSeconds = totalSeconds % 60
const components = []
// If the floored value is 0, it gets skipped.
// Example: 583 seconds -> 0 flooredHours, 9 minutes and 43 seconds
if (flooredHours > 0) {
components.push(`${flooredHours} hour${flooredHours > 1 ? "s" : ""}`)
}
if (flooredMinutes > 0) {
components.push(`${flooredMinutes} minute${flooredMinutes > 1 ? "s" : ""}`)
}
if (remainingSeconds > 0) {
components.push(`${remainingSeconds} second${remainingSeconds > 1 ? "s" : ""}`)
}
switch (components.length) {
case 1:
return `${components[0]}`
case 2:
return `${components[0]} and ${components[1]}`
case 3:
return `${components[0]}, ${components[1]} and ${components[2]}`
}
}
Focusing purely on the cosmetics, a tapped card should reveal a menu consisting of:
The timer’s string is constructed with the padStart function and our flooredHours flooredMinutes and remainingSeconds.
// Formatted string for the timer (initially work time)
export function formatTimerString(totalSeconds: number) {
const flooredHours = Math.floor(totalSeconds / 3600);
const secondsWithoutHours = totalSeconds % 3600;
const flooredMinutes = Math.floor(secondsWithoutHours / 60);
const remainingSeconds = totalSeconds % 60;
return `${flooredHours.toString().padStart(2, "0")}:${flooredMinutes
.toString()
.padStart(2, "0")}:${remainingSeconds.toString().padStart(2, "0")}`;
}
A TimerMenu component is conditionally rendered upon tapping the card. This version only displays the formatted time string accompanied by the items mentioned above. The next section will focus on changing the timer’s display depending on the current phase in our set.
For a number of sets, the main flow our timer should adhere to is:
prepping → working → resting → working → …
Specifically, the prepping phase happens once, and the working resting phases cycle runs until the end of our sets. There are special cases we consider for completeness such as not including a resting phase after our final working phase.
On the technical side, we conveniently define a new type TimerPhase.
// Prepping <=> Starting in...
type TimerPhase = "Starting in..." | "Working" | "Resting" | "Finished"
and the state variables we’ll be working with:
// TimerMenuV2.tsx
interface TimerProps {
title: string
workTime: number
restTime: number
prepTime: number
sets: number
clr: string
}
// import {useState} from "preact/hooks" or any other React library
export default function TimerMenuV2({workTime,r restTime, prepTime, sets, clr}: TimerProps) {
const [isPlaying, setIsPlaying] = useState<boolean>(false)
const [currentPhase, setCurrentPhase] = useState<TimerPhase>("Starting in...")
const [remainingSets, setRemainingSets] = useState<number>(sets)
const [timeLeft, setTimeLeft] = useState(prepTime ? prepTime : workTime)
return (
// ...
)
}
To accomplish our goal, we’ll be manoeuvering with two useEffect hooks having distinct purposes. The first one, call it the countdown useEffect, oversees that we decrement the timeLeft every second. This is done by creating a timer through the setInterval function. That same hook cleans up the timer (removes it from memory) every time isPlaying changes or if the component unmounts. The clearInterval is the function in charge of being the janitor. The code snippet below directly translates the functionality discussed.
// Countdown useEffect
useEffect(() => {
// (*) Guard here, because useEffect doensn't care about the initial state of isPlaying
if (!isPlaying) return;
const timer = setInterval(() => {
setTimeLeft(prev => prev - 1);
}, 1000);
// Every time the effect re-runs or when the component unmounths, the old timer gets cleared.
return () => clearInterval(timer);
}, [isPlaying])
Note: Regardless of whether the play button was tapped, the useEffect above will runs on its own as soon as the component mounts. For this reason, there’s a guard that returns from the function when the isPlaying state is false.
The other hook, name it the phase useEffect, switches our phase state when the timeLeft hits 0. For example, If we finish a working phase in a Quick Coding mission, we’d have to modify the currentPhase from “Working” to “Resting” , and set the timeLeft to the restTime of 60 seconds. The sets are decremented as well.
useEffect(() => {
{/* Other logic branches not shown now */}
if (timeLeft != 0) return;
switch (currentPhase) {
case "Starting in...":
setCurrentPhase("Working")
setTimeLeft(workTime)
break
case "Working":
setCurrentPhase("Resting")
setTimeLeft(restTime)
setRemainingSets(prev => prev - 1)
break;
case "Resting":
setCurrentPhase("Working")
setTimeLeft(workTime)
break;
}
}, [timeLeft])
No comments on this post yet. Be the first to share your wisdom :)