Build an Accurate Stopwatch Timer in React: Step-by-Step Guide

Explore techniques for implementing a precise stopwatch timer and avoiding common pitfalls. This guide details building reliable timers for any application.

Published on

3 min read

Table of contents

Creating a stopwatch timer may seem straightforward, but many developers often implement timers inaccurately, leading to time loss.

A common approach is to increment the time by one second or millisecond as needed, which might appear correct on paper. However, when compared with a precise stopwatch timer, this method tends to count slower over time.

// Wrong Approach!!
const [time, setTime] = useState(0);
const [isRunning, setIsRunning] = useState(false);

useEffect(() => {
  let intervalId
  if (isRunning) intervalId = setInterval(() => setTime(time + 1), 1);
  return () => clearInterval(intervalId);
}, [isRunning, time]);

return <div>
  {displayTime(time)}
  <StartButton />
</div>;

In this article, I will show you a method to implement a stopwatch timer without losing time accuracy.

First, create a new file for the stopwatch component, and prepare the layout:

import React, { useState, useEffect, useRef } from "react";

const SECOND = 1000;
const MINUTE = SECOND * 60;
const HOUR = MINUTE * 60;

export default function StopwatchTimer() {
  const [time, setTime] = useState<number>(0); // stores the time difference
  
  // Separate the unit values from time
  const hours = Math.floor(time / HOUR);
  const minutes = Math.floor((time / MINUTE) % 60);
  const seconds = Math.floor((time / SECOND) % 60);
  const milliseconds = time % SECOND;
  
  const start = () => {};
  
  const stop = () => {};
  
  const reset = () => {};
  
  return (
  <div>
    {/ Use monospace font family to prevent numbers from jumping /}
    <div style={{ fontFamily: "monospace" }}>
      {hours}:
      {minutes.toString().padStart(2, "0")}:
      {seconds.toString().padStart(2, "0")}.
      {milliseconds.toString().padStart(3, "0")}
    </div>
    <div>
      <button onClick={start}>Start</button>
      <button onClick={stop}>Stop</button>
      <button onClick={reset}>Reset</button>
    </div>
  </div>
  );
}

Next, we need to calculate the time correctly. Instead of doing increments, we should calculate the time difference between the start time and the current time.

Create a time state to store the time difference, and isRunning state to mark if the timer is active.

const startTimeRef = useRef<number>(0);
const [time, setTime] = useState<number>(0);
const [isRunning, setIsRunning] = useState(false);

Complete the functions for start, stop, and reset:

1. start: store the start time and toggle the active state (isRunning).

2. stop: set isRunning to inactive.

3. reset: stop the timer and set the time to 0.

const start = () => {
  startTimeRef.current = Date.now();
  setIsRunning(true);
};

const stop = () => {
  setIsRunning(false);
};

const reset = () => {
  stop();
  setTime(0);
};

Now that everything is set, we can add the main function. Since we start the timer by setting isRunning to true, we can monitor it with useEffect. When isRunning is true, we can use setInterval() to calculate the time difference between current time and start time, Date.now() - startTimeRef.current) in this instance.

useEffect(() => {
  let intervalId: NodeJS.Timeout;
  if (isRunning) {
    intervalId = setInterval(() => setTime(Date.now() - startTimeRef.current), 1);
  }
  return () => clearInterval(intervalId);
}, [isRunning]);

For better visuals, the interval is set to 1 millisecond, meaning the timer will update every millisecond. Feel free to set the interval to any duration you like, the stopwatch time would still be accurate. :D

Complete Code

import React, { useState, useEffect, useRef } from "react";

const SECOND = 1000;
const MINUTE = SECOND * 60;
const HOUR = MINUTE * 60;

export default function StopWatchTimer() {
  const startTimeRef = useRef<number>(0);
  const [time, setTime] = useState<number>(0);
  const [isRunning, setIsRunning] = useState(false);
  
  useEffect(() => {
    let intervalId: NodeJS.Timeout;
    if (isRunning) {
      intervalId = setInterval(() => setTime(Date.now() - startTimeRef.current), 1);
  }
  return () => clearInterval(intervalId);
  }, [isRunning]);
  
  const hours = Math.floor(time / HOUR);
  const minutes = Math.floor((time / MINUTE) % 60);
  const seconds = Math.floor((time / SECOND) % 60);
  const milliseconds = time % SECOND;
  
  const start = () => {
    startTimeRef.current = Date.now();
    setIsRunning(true);
  };
  
  const stop = () => {
    setIsRunning(false);
  };
  
  const reset = () => {
    stop();
    setTime(0);
  };
  
  return (
    <div>
      <div style={{ fontFamily: "monospace" }}>
        {hours}:
        {minutes.toString().padStart(2, "0")}:
        {seconds.toString().padStart(2, "0")}.
        {milliseconds.toString().padStart(3, "0")}
      </time>
      <div>
        <button onClick={start}>Start</button>
        <button onClick={stop}>Stop</button>
        <button onClick={reset}>Reset</button>
      </div>
    </div>
  );
}