Learn and Teach Coding

React Stopwatch

A stopwatch with start, stop, and reset built with useState, useRef, and useEffect.

ReactBeginnerstatetimersuseRef

Live preview

00:00:00.00

Source code

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

function format(ms: number) {
  const totalCs = Math.floor(ms / 10);
  const cs = totalCs % 100;
  const totalSec = Math.floor(totalCs / 100);
  const sec = totalSec % 60;
  const totalMin = Math.floor(totalSec / 60);
  const min = totalMin % 60;
  const hr = Math.floor(totalMin / 60);
  const pad = (n: number) => String(n).padStart(2, "0");
  return `${pad(hr)}:${pad(min)}:${pad(sec)}.${pad(cs)}`;
}

export default function Stopwatch() {
  const [ms, setMs] = useState(0);
  const [running, setRunning] = useState(false);
  const startedAt = useRef(0);

  useEffect(() => {
    if (!running) return;
    // Anchor to a fixed origin so pausing/resuming stays accurate.
    startedAt.current = performance.now() - ms;
    let frame = 0;
    const tick = () => {
      setMs(performance.now() - startedAt.current);
      frame = requestAnimationFrame(tick);
    };
    frame = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(frame);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [running]);

  return (
    <div className="flex flex-col items-center gap-6">
      <div className="font-mono text-5xl font-bold tabular-nums tracking-tight text-slate-900 dark:text-white sm:text-6xl">
        {format(ms)}
      </div>
      <div className="flex items-center gap-3">
        <button
          type="button"
          onClick={() => setRunning(true)}
          disabled={running}
          className="rounded-lg bg-emerald-500 px-5 py-2 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-emerald-600 disabled:cursor-not-allowed disabled:opacity-50"
        >
          Start
        </button>
        <button
          type="button"
          onClick={() => setRunning(false)}
          disabled={!running}
          className="rounded-lg bg-rose-500 px-5 py-2 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-rose-600 disabled:cursor-not-allowed disabled:opacity-50"
        >
          Stop
        </button>
        <button
          type="button"
          onClick={() => {
            setRunning(false);
            setMs(0);
          }}
          className="rounded-lg bg-slate-200 px-5 py-2 text-sm font-semibold text-slate-700 transition-colors hover:bg-slate-300 dark:bg-slate-700 dark:text-slate-100 dark:hover:bg-slate-600"
        >
          Reset
        </button>
      </div>
    </div>
  );
}

Walkthrough

A stopwatch is a great way to learn how state, refs, and effects work together. We render the elapsed time from state, but keep the timing origin in a ref so it survives re-renders without triggering them. The full source sits beside this walkthrough.

1. State and the timing ref

ms drives what the user sees and running toggles the timer. startedAt is a ref, not state — changing it must not re-render, it just records when the current run began.

const [ms, setMs] = useState(0);
const [running, setRunning] = useState(false);
const startedAt = useRef(0);

2. The animation loop

When running flips on, the effect anchors startedAt to a fixed origin, then drives updates with requestAnimationFrame for buttery-smooth ticks.

useEffect(() => {
  if (!running) return;
  startedAt.current = performance.now() - ms;
  let frame = 0;
  const tick = () => {
    setMs(performance.now() - startedAt.current);
    frame = requestAnimationFrame(tick);
  };
  frame = requestAnimationFrame(tick);
  return () => cancelAnimationFrame(frame);
}, [running]);

Subtracting the existing ms (performance.now() - ms) is what makes pause/resume accurate — we resume from where we left off instead of restarting at zero.

3. Cleanup

The effect returns cancelAnimationFrame(frame). React runs that when running becomes false (or the component unmounts), so we never leak a runaway loop.

4. Formatting the time

format converts milliseconds into hh:mm:ss.cs, zero-padding each part. Keeping it a pure helper outside the component means it never gets recreated on render.

function format(ms: number) {
  const cs = Math.floor(ms / 10) % 100;
  const sec = Math.floor(ms / 1000) % 60;
  const min = Math.floor(ms / 60000) % 60;
  // ...pad and join
}

5. The controls

Start sets running to true, Stop sets it false, and Reset stops and zeroes ms. Each button is disabled when it wouldn't make sense (you can't start an already-running clock). See the full source for the exact markup.