React Stopwatch
A stopwatch with start, stop, and reset built with useState, useRef, and useEffect.
Live preview
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.