Stopwatch Timer
Machine-code a stopwatch: elapsed from startedAt, pause stores the accumulated time, never increment a counter on an interval tick.
- machine-coding
- interview
- react
- time
The first stopwatch most people write is a lie. They setInterval(() => setMs((m) => m + 10), 10) and call that elapsed time. The tab sleeps, the browser clamps the timer, and after a minute the display is short. A stopwatch timer stores when the run started and how much had already elapsed. The clock is Date.now(). The interval only paints.
Pause is not “stop incrementing.” Pause folds the open interval into accumulated and clears startedAt. Resume writes a new startedAt. Reset zeros both. If you cannot say those three transitions out loud, the CSS digits will not save the round.
Key takeaways
- While running,
elapsed = accumulated + (Date.now() - startedAt). While paused,elapsedisaccumulated.- The interval (or rAF) rereads
Date.now()and formats. It never doescount++.- Pause adds the open interval into
accumulatedand setsstartedAttonull.- Start/stop is a real button, so Space and Enter work. Do not bind Space on
window.- A live region announces running or paused, not every centisecond.
elapsed from startedAt, pause stores accumulated, never increment a counter
State is two numbers, not a ticking integer:
type StopwatchState = {
accumulated: number; // ms already banked while paused
startedAt: number | null; // Date.now() of the current run, or null
};
function elapsed(state: StopwatchState, now = Date.now()) {
if (state.startedAt == null) return state.accumulated;
return state.accumulated + (now - state.startedAt);
}
function start(state: StopwatchState, now = Date.now()): StopwatchState {
if (state.startedAt != null) return state;
return { ...state, startedAt: now };
}
function pause(state: StopwatchState, now = Date.now()): StopwatchState {
if (state.startedAt == null) return state;
return {
accumulated: state.accumulated + (now - state.startedAt),
startedAt: null,
};
}
function reset(): StopwatchState {
return { accumulated: 0, startedAt: null };
}
startedAt == null means paused (or never started). That is the running flag. You do not need a third isRunning boolean unless you like desyncing it.
Why counting on the interval is wrong: setInterval(fn, 10) is a scheduler, not a clock. Hidden tabs often get a 1000 ms clamp. The callback can be late, skipped, or bunched. Ten late ticks are 100 ms of fiction. Date.now() - startedAt is wall time. After a sleep, the next paint jumps to the truth. Same idea as the analog clock: read Date, do not count.
The display loop is allowed to be coarse. 50 ms or one rAF is enough for hundredths. Alignment to a 10 ms boundary is optional polish.
function formatMs(ms: number) {
const clamped = Math.max(0, ms);
const m = Math.floor(clamped / 60000);
const s = Math.floor((clamped % 60000) / 1000);
const cs = Math.floor((clamped % 1000) / 10);
const pad = (n: number, w = 2) => String(n).padStart(w, "0");
return `${pad(m)}:${pad(s)}.${pad(cs)}`;
}
Keep formatting pure. Tests can pass a frozen now. For a fake clock in unit tests, see implement a fake setTimeout. Do not spin on Date.now() in the test host.
Problem statement
Build a stopwatch: start, pause, resume, reset. Elapsed time is derived from timestamps. Keyboard can start and stop. Screen readers hear running versus paused without a stream of ticks.
Requirements
Must have
- Visible
mm:ss.cc(ormm:ss) - Start, pause/resume, reset
- Elapsed from
accumulated+ open interval - Interval or rAF used only to refresh the label
- Keyboard start/stop via the toggle button
- Live region for running / paused / reset
Should have
- Resume continues from the banked total, not from zero
- Clear the ticker on pause, unmount, and reset
aria-pressedon the toggle, or distinct Start / Pause labels
Nice to have
- Laps (store
elapsed()at click, do not copy the label string as source of truth) - Split vs lap
- Keyboard
Lfor lap while the widget contains focus
Planning (5 minutes out loud)
- Model:
accumulatedandstartedAt, deriveelapsed - MVP: start, pause, resume, reset, formatted time
- Ticker: start interval only when
startedAtis set; cleanup in the effect - Then button names and a polite live region
- Do not store
msthat you increment
Architecture
Stopwatch
├── Time (role="timer", aria-live="off")
├── Status (role="status")
├── Toggle (Start / Pause)
└── Reset
API
type StopwatchProps = {
onChange?: (ms: number, running: boolean) => void;
};
Implementation sketch
function Stopwatch({ onChange }: StopwatchProps) {
const [accumulated, setAccumulated] = useState(0);
const [startedAt, setStartedAt] = useState<number | null>(null);
const [now, setNow] = useState(() => Date.now());
const running = startedAt != null;
const ms = elapsed({ accumulated, startedAt }, running ? now : Date.now());
useEffect(() => {
if (startedAt == null) return;
const id = setInterval(() => setNow(Date.now()), 50);
return () => clearInterval(id);
}, [startedAt]);
useEffect(() => {
onChange?.(ms, running);
}, [ms, running, onChange]);
function toggle() {
const t = Date.now();
if (startedAt == null) {
setNow(t);
setStartedAt(t);
} else {
setAccumulated(accumulated + (t - startedAt));
setStartedAt(null);
}
}
function onReset() {
setAccumulated(0);
setStartedAt(null);
setNow(Date.now());
}
const status = running ? "Running" : ms === 0 ? "Ready" : "Paused";
return (
<div>
<p role="timer" aria-live="off" aria-label={`Elapsed ${formatMs(ms)}`}>
{formatMs(ms)}
</p>
<p role="status" aria-live="polite">
{status}
</p>
<button type="button" onClick={toggle} aria-pressed={running}>
{running ? "Pause" : "Start"}
</button>
<button type="button" onClick={onReset}>
Reset
</button>
</div>
);
}
The effect depends on startedAt, not on now. If you put now in the deps, you restart the interval every 50 ms and the cadence is junk. That is the same stale-versus-restart trap as useInterval: the timer identity stays stable, the read of Date.now() is fresh.
aria-live="off" on the digits is deliberate. A live region that updates twenty times a second is unusable. Status changes three ways: Ready, Running, Paused. That is what AT should hear.
role="timer" marks the numeric readout. It is a live-region role in ARIA, so keep aria-live="off" (or do not duplicate a polite live on the same node) so the role does not start announcing ticks.
Keyboard start/stop: the toggle is a <button>. Space and Enter already activate it when focused. A global Space handler steals Space from inputs and from the Reset button. If they insist on a shortcut while the widget is focused, listen on the container and preventDefault only when the target is not another button. Prefer the labeled control.
React Strict Mode will start and stop the interval twice in development. Cleanup must clearInterval. That is expected, not a bug in the model.
Accessibility essentials
| Piece | Approach |
|---|---|
| Digits | Text in the tree, role="timer", aria-live="off" |
| State | role="status" for Running / Paused / Ready |
| Toggle | <button> with Start or Pause text, optional aria-pressed |
| Reset | Separate button, never a clickable heading |
| Keys | Native button activation. No window-level Space |
| Time | Accessible name includes “Elapsed” plus the formatted value |
Do not put the time only in a canvas or a CSS content string. If images of digits are a stretch goal, they still need the text alternative.
Interview scoring
| Signal | What they want |
|---|---|
| Model | elapsed = accumulated + (now - startedAt) said out loud |
| Pause | Bank into accumulated, clear startedAt |
| Ticker | Display only; survives tab sleep |
| Cleanup | clearInterval on pause and unmount |
| A11y | Buttons, status live region, no tick spam |
| Resume | Continues, does not jump to zero |
They may background the tab or ask “what if the interval is late?” If you counted, you fail that question. If you read the clock, you pass it.
Footguns
setMs((m) => m + 10)on the interval. This is the fail they are fishing for.isRunningplusstartedAtthat disagree after reset.- Leaving the interval on while paused, wasting wakes and risking a stray tick that mutates state.
- Announcing every tick in a live region.
- Pause that sets a flag but leaves
startedAtset, so the next paint includes time spent paused. Date.now()in render with no ticker while running: some screens never refresh; others refresh too often if a parent re-renders. Drivenowfrom the interval.
performance.now() is fine too. It is monotonic and not wall-clock. For a stopwatch, either works. Date.parse and time zones are irrelevant here; you are subtracting two instants. See Date and time pitfalls if they drag you into formatting locales. Intl.DateTimeFormat is the wrong tool for a duration.
Lap note, if they ask: push elapsed(state) onto an array. Do not parse the mm:ss.cc string back into ms. The label is a view.
Interview out-loud answer
I would keep
accumulatedmilliseconds and astartedAttimestamp. While running, elapsed isaccumulated + (Date.now() - startedAt). Pause adds that open interval intoaccumulatedand clearsstartedAt. Reset zeros both. An interval or rAF only callssetNow(Date.now())so the label repaints. I would never increment a counter on a tick, because a throttled timer would drift. Start and pause are one button, so keyboard activation is free. A polite status region announces running or paused, and the digits are not live.
Related on this site
- Analog Clock
- Implement a Fake setTimeout
- useInterval Hook
- aria-live Regions
- Machine Coding Interview Framework