How to build a countdown timer in React
A reusable useCountdown hook that handles the three things the standard useEffect plus setInterval version gets wrong: accumulated drift, throttled background tabs, and counting against a clock the visitor controls. The code below is complete and copy-pasteable, and works with any backend — the API-specific part is one fetch.
What goes wrong, and why
The usual implementation decrements a counter inside a one-second interval. Four things break it, none of which show up while you are developing.
| Problem | Cause | Fix used below |
|---|---|---|
| Accumulated drift | setInterval is a minimum delay. Decrementing compounds each late tick. | Compute from a fixed end timestamp every tick. |
| Jumps after a background tab | Hidden tabs are throttled to ~1s, then ~1min. | Recompute on visibilitychange. |
| Wrong on a device with a bad clock | Date.now() is the visitor’s clock, not yours. | Measure skew against server_time once, subtract it. |
| Flash of zero on mount | useState(0) renders before the first tick. | Seed state from the server value; render a placeholder until it arrives. |
The hook
It takes a remaining-seconds value and the server clock that produced it, and returns a broken-down countdown that stays accurate.
hooks/use-countdown.ts
import { useEffect, useRef, useState } from "react";
type Source = {
/** Seconds remaining, from the server. */
remainingSeconds: number;
/** The server's clock at the moment it produced that number. */
serverTime: string;
};
/**
* A countdown that ticks locally but counts against the server.
*
* The visitor's clock is measured against the server's once, and
* the difference is subtracted on every tick. A device that is an
* hour fast still shows the correct remaining time.
*/
export function useCountdown({ remainingSeconds, serverTime }: Source) {
const [left, setLeft] = useState(remainingSeconds);
const skew = useRef(0);
const endsAt = useRef(0);
useEffect(() => {
skew.current = Date.now() - new Date(serverTime).getTime();
endsAt.current = Date.now() - skew.current + remainingSeconds * 1000;
setLeft(remainingSeconds);
}, [remainingSeconds, serverTime]);
useEffect(() => {
function tick() {
const now = Date.now() - skew.current;
setLeft(Math.max(0, Math.round((endsAt.current - now) / 1000)));
}
tick();
const id = setInterval(tick, 1000);
// A throttled or restored tab has a stale value. Recompute
// as soon as it is visible instead of waiting for the interval.
document.addEventListener("visibilitychange", tick);
return () => {
clearInterval(id);
document.removeEventListener("visibilitychange", tick);
};
}, []);
return {
seconds: left,
ended: left === 0,
days: Math.floor(left / 86400),
hours: Math.floor((left % 86400) / 3600),
minutes: Math.floor((left % 3600) / 60),
remainder: left % 60,
};
}The two refs
skew is how far the visitor's clock sits from ours, measured once. endsAt is the deadline in corrected local time. Both are refs rather than state because changing them should not trigger a render — only the displayed second should.
Recomputing from endsAt on every tick is what removes drift. A tick that arrives 40ms late produces the same number as one that arrives on time; a tick that is skipped entirely just means the display moves by two seconds instead of one.
Getting the server value in
The hook needs a starting point. Fetch it through your own backend — the API key must never reach the browser.
hooks/use-timer.ts
import { useEffect, useState } from "react";
/**
* Reads status through YOUR backend, never the API directly —
* an API key in frontend JavaScript is a public API key.
*/
export function useTimer(timerId: string, resyncMs = 60_000) {
const [source, setSource] = useState<Source | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
const response = await fetch(`/api/countdown/${timerId}`);
if (!response.ok) return;
const data = await response.json();
if (!cancelled) {
setSource({
remainingSeconds: data.remaining_seconds,
serverTime: data.server_time,
});
}
}
load();
// Re-sync occasionally so a long-lived tab cannot drift, and
// whenever the tab becomes visible again.
const id = setInterval(load, resyncMs);
document.addEventListener("visibilitychange", load);
return () => {
cancelled = true;
clearInterval(id);
document.removeEventListener("visibilitychange", load);
};
}, [timerId, resyncMs]);
return source;
}visibilitychange — the local tick is accurate enough between visits.Putting it together
components/countdown.tsx
export function Countdown({ timerId }: { timerId: string }) {
const source = useTimer(timerId);
// Nothing to count against yet — render a stable placeholder
// rather than a zero that will visibly jump.
if (!source) return <span aria-hidden="true">--:--:--</span>;
return <Clock source={source} />;
}
function Clock({ source }: { source: Source }) {
const { days, hours, minutes, remainder, ended } = useCountdown(source);
if (ended) return <p>This offer has closed.</p>;
return (
<time
dateTime={`P${days}DT${hours}H${minutes}M${remainder}S`}
aria-live="polite"
aria-atomic="true"
>
{days > 0 && <>{days}d </>}
{String(hours).padStart(2, "0")}:
{String(minutes).padStart(2, "0")}:
{String(remainder).padStart(2, "0")}
</time>
);
}The accessibility details
A <time> element with dateTime gives assistive technology and crawlers a machine-readable duration. aria-live="polite" announces changes without interrupting — assertive on a per-second update makes a screen reader unusable. The placeholder is aria-hidden because “dash dash colon” is noise.
When you need sub-second precision
Rarely, but auctions and live bidding are the real cases. requestAnimationFrame is smoother than setInterval and stops on its own when the tab is hidden, which sidesteps throttling entirely.
Sub-second variant
// If you need sub-second precision — an auction closing, say —
// requestAnimationFrame is smoother than setInterval and pauses
// itself when the tab is hidden.
useEffect(() => {
let frame: number;
function loop() {
const now = Date.now() - skew.current;
setMs(Math.max(0, endsAt.current - now));
frame = requestAnimationFrame(loop);
}
frame = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frame);
}, []);
// Only reach for this when the extra precision is visible to a
// user. At one-second granularity setInterval is cheaper.For anything counting in whole seconds this is wasted work — sixty renders a second to change a number once. Reach for it only when the extra precision is visible.
The part a hook cannot do
Everything above is presentation. When the countdown reaches zero, the browser knows and your server does not — so whatever the deadline was meant to trigger will not happen, and a visitor who never opened the page is not counted at all.
That needs a server-side deadline with a callback: webhooks fire at zero, or at any milestone before it. And at the moment the deadline gates something real, re-check server-side rather than trusting the value the browser submitted.
Common questions
Why does my React countdown drift?
setInterval guarantees a minimum delay, not an exact one. Each tick arrives slightly late, and if you decrement a counter those errors accumulate — several seconds over an hour. Computing remaining time from a fixed end timestamp on every tick, as the hook here does, means a late tick simply skips a number instead of compounding.
Why does the countdown jump when I switch back to the tab?
Browsers throttle timers in hidden tabs to roughly once per second, then to once per minute after a few minutes. The interval genuinely stops firing at the rate you asked for. Listening to visibilitychange and recomputing immediately fixes the visible jump.
Should I put the API key in my React app?
No. Anything in frontend JavaScript is public — the network tab shows every request. Route the call through your own backend, or use the public output URLs a published timer provides, which need no key at all.
Do I need a state management library for this?
No. A countdown is local, self-contained state that changes every second — putting it in Redux or Zustand means a global re-render per tick for no benefit. Keep it in the component that displays it.
Next steps
Wire the hook to a real deadline
Sandbox is free with any account. Create a timer, point your proxy route at it, and the hook works unchanged.