Why This Question Exists
Interviewers use the Stopwatch to test one specific thing: do you understand stale closures in React? Almost every candidate reaches for setInterval(() => setTime(t => t + 100), 100) first. That looks reasonable — but it silently drifts because setInterval doesn't fire at exactly the interval you specify. Over 60 seconds you can lose 1–2 seconds of accuracy.
The second trap is the stale closure. If you write setInterval(() => setElapsed(elapsed + 10), 10), the closure captures the initial value of elapsed (zero) and never re-reads it. Every tick adds 10 to zero. The display freezes at 10ms. This bug is invisible — no error, wrong output.
The Wrong Approach (and why it fails)
// ❌ WRONG — stale closure bug
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setElapsed(elapsed + 10); // 'elapsed' is always 0 here
}, 10);
return () => clearInterval(id);
}, []); // [] means closure never updates
The empty dependency array means the effect only runs once. The elapsed inside the callback is permanently captured as 0. Even with the functional updater form setElapsed(e => e + 10), you're still subject to timer drift over time.
The Right Approach: Wall-Clock Anchoring
// ✅ CORRECT — wall-clock anchoring
const startRef = useRef(0); // wall-clock when started
const savedRef = useRef(0); // accumulated ms before last pause
const start = () => {
startRef.current = Date.now() - savedRef.current;
intervalRef.current = setInterval(() => {
const elapsed = Date.now() - startRef.current;
savedRef.current = elapsed;
setDisplay(elapsed); // only state needed for render
}, 10);
};
The key: Date.now() - startRef.current always gives the correct elapsed time regardless of how late the interval fires. If a tick is delayed by 5ms, the next tick reads 5ms ahead to compensate. No drift, no stale closure.
Ref vs State — the core principle
Ask yourself: "Does changing this value need to trigger a re-render?" For intervalId — no. For startTime — no. For savedElapsed — no. For displayMs — yes (this is what the user sees). So only displayMs goes in state. Everything else goes in refs.
ℹ Interview TipSay: "I use wall-clock anchoring — recording Date.now() when the timer starts and computing elapsed on each tick — rather than accumulating ticks. This gives accurate results even if the browser throttles setInterval under load or when the tab is in the background."
Pause and Resume Logic
const pause = () => {
clearInterval(intervalRef.current);
// savedRef.current already holds the elapsed — updated on every tick
setRunning(false);
};
const resume = () => {
// Shift the anchor back by what's already elapsed
startRef.current = Date.now() - savedRef.current;
intervalRef.current = setInterval(() => { ... }, 10);
setRunning(true);
};
On pause, savedRef.current already holds the exact elapsed milliseconds (updated by every tick). On resume, we re-anchor: startRef.current = Date.now() - savedRef.current. The next tick will compute Date.now() - startRef.current which correctly continues from the saved point.
Memory Leak Prevention
useEffect(() => {
return () => clearInterval(intervalRef.current); // cleanup on unmount
}, []);
Without this, navigating away from the component leaves a setInterval running in the background indefinitely. It will call setDisplay on an unmounted component, triggering React's "Can't perform a state update on an unmounted component" warning — and burning CPU.
Time Formatting
function fmt(ms) {
const cs = Math.floor(ms / 10) % 100; // centiseconds (0-99)
const s = Math.floor(ms / 1000) % 60; // seconds (0-59)
const m = Math.floor(ms / 60000); // minutes (unbounded)
return `${pad(m)}:${pad(s)}.${pad(cs)}`;
}
const pad = n => String(n).padStart(2, '0');
Each unit is derived from raw ms using integer division and modulo. The modulo ensures seconds wrap at 60 (not counting up past 59). Minutes are unbounded — a stopwatch can run for hours.
⚠ Common Pitfall: clearInterval in the wrong placeCalling clearInterval only in the reset handler but not in useEffect cleanup means unmounting while the timer is running leaves an orphaned interval. Always clean up in both places.