Skip to content

x1ee7/react-drift-timer

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

@x1ee7/react-drift-timer

CI npm license ![Sponsor](https://img.shields.io/badge/sponsor-%E2%9D%A4- db61a2)

Drift-resistant React countdown timer hook. A setInterval-based countdown loses a few milliseconds on every tick, and loses seconds when the tab is backgrounded — by minute 30 of a 30-minute exam timer, the clock has wandered off the wall. useTimer from @x1ee7/react-drift-timer fixes this by calculating remaining time from the absolute start timestamp on every tick. The displayed countdown matches a wall clock to the second, even after the tab sleeps, the laptop suspends, or React skips frames.

This is the production timer powering exam sessions on humberrealestate.com — a real-estate exam prep platform where mis-timing a 90-minute proctored simulation by even a few seconds would be a bug report. The hook is open; the question bank stays in the app.

Install

npm install @x1ee7/react-drift-timer
pnpm add @x1ee7/react-drift-timer
# or: yarn add @x1ee7/react-drift-timer

React 17+ peer dependency. ESM + CommonJS + TypeScript types. No runtime dependencies.

Usage

import { useTimer } from "@x1ee7/react-drift-timer";

function QuizTimer({ seconds, onTimeUp }: { seconds: number; onTimeUp: () => void }) {
  const { timeRemaining, isRunning, pause, resume } = useTimer(seconds, onTimeUp);

  if (timeRemaining === null) return null;

  const mm = Math.floor(timeRemaining / 60);
  const ss = String(timeRemaining % 60).padStart(2, "0");

  return (
    <div>
      <span>{mm}:{ss}</span>
      <button onClick={isRunning ? pause : resume}>
        {isRunning ? "Pause" : "Resume"}
      </button>
    </div>
  );
}

Pass null for initialSeconds to disable the timer (it returns { timeRemaining: null, isRunning: false }). When initialSeconds changes to a new number, the timer resets and starts again — useful for "new quiz started" or "next question" flows.

API

Export Signature Notes
useTimer (initialSeconds: number | null, onExpire: () => void) => UseTimerReturn The hook itself.
UseTimerReturn { timeRemaining, isRunning, pause, resume } Return type. timeRemaining is null while the timer is disabled.

UseTimerReturn shape:

interface UseTimerReturn {
  timeRemaining: number | null;
  isRunning: boolean;
  pause: () => void;
  resume: () => void;
}

Why React setInterval countdowns drift

The naive countdown looks like this:

// ❌ drifts
useEffect(() => {
  const id = setInterval(() => setSeconds((s) => s - 1), 1000);
  return () => clearInterval(id);
}, []);

Three things go wrong:

  1. setInterval is not precise. The browser only guarantees "at least 1000 ms"; it can be 1003 ms, 1015 ms, sometimes more. Each tick drops the slop on the floor and starts a new interval, so error compounds.
  2. Background tabs throttle. Chrome and Safari throttle background timers to once-per-minute (or longer). A setInterval(fn, 1000) running in a backgrounded tab fires maybe 60× less often than it should — and the s - 1 math has no idea the tab was hidden, so the clock falls behind by the entire backgrounded duration.
  3. React batching delays ticks. State updates can be deferred behind layout or transitions, so the setSeconds call doesn't always land immediately.

useTimer sidesteps all three by storing the absolute start time in a ref and computing remaining = initialSeconds − floor((Date.now() − startTime) / 1000) on every tick. The displayed value is derived from the wall clock, not accumulated. Skipped ticks self-heal on the next one. Backgrounded tabs catch up the moment they become visible.

The tick cadence is 4×/second (setTimeout(tick, 250)) — enough for a sub-second-feeling countdown, not so fast that it floods React's scheduler.

Pause and resume preserve absolute time

pause() flips isRunning to false, which clears the next scheduled tick. resume() does the inverse, but it also adjusts startTimeRef so the elapsed-time math stays consistent:

startTimeRef.current = Date.now()  ((initialSeconds  timeRemaining) × 1000);

In other words, resuming a paused timer is mathematically the same as if the pause had never happened — the countdown picks up exactly where it stopped, not at the moment of resume. This is what users expect from a stopwatch.

onExpire fires exactly once

When the countdown reaches 0, onExpire is called once and isRunning flips to false. The internal hasExpiredRef guards against repeat fires (which can otherwise happen if React re-runs the effect, or if the timer is paused and resumed past zero). If you swap the onExpire callback between renders, the latest callback is used — the timer holds a ref to it that updates without resetting the countdown.

When you should NOT use this

  • Hard real-time guarantees. This is browser JavaScript; sub-100ms precision is not a goal. The hook is accurate to the second.
  • Stopwatches (counting up). This hook only counts down. A drift-resistant stopwatch is the same idea (Date.now() − startTime) but doesn't need expiration handling — write it inline.
  • Server-side timers. This is a client hook with a "use client" directive. Server-side schedules belong in a job queue, not a React hook.

Comparison

Approach Drifts on long timers Survives backgrounded tab Pause / resume Bundle
setInterval + decrement yes no manual tiny
requestAnimationFrame loop yes (rAF pauses in bg) no manual tiny
Date-based on every tick (this hook) no yes built-in ~1 KB gz
react-countdown / react-timer-hook varies varies yes larger

FAQ

Why does my React countdown drift?

setInterval only guarantees "at least 1000 ms" between fires, so the few extra milliseconds on every tick compound into a visible offset over a long countdown. Background tabs make it worse — Chrome throttles timers to roughly once per minute when the tab is hidden, so a tab-switched countdown can fall behind by minutes. The fix is to derive the displayed value from the wall clock (Date.now() − startTime) instead of subtracting on every tick.

How do I make a countdown timer in React without drift?

Store the start timestamp in a ref. On every tick, compute remaining = initialSeconds − Math.floor((Date.now() − startTime) / 1000). The tick interval can be 250 ms or 1000 ms — it doesn't matter, because the displayed value is recomputed from absolute time each time, not accumulated. That's the entire idea behind useTimer.

Does this hook work if the tab is backgrounded?

Yes. While the tab is hidden, the browser throttles setTimeout so ticks fire much less often — but the first tick after the tab regains focus recomputes remaining from Date.now() − startTime and immediately catches up. There is no compounding error.

Can I pause and resume the timer?

Yes. The hook returns pause and resume functions. pause stops the countdown; resume continues it from exactly the second it stopped at, with no drift introduced by the pause itself (the start timestamp is rewound internally to keep the math consistent).

How do I detect when the timer reaches zero?

Pass an onExpire callback. It is called exactly once when the countdown hits 0, even if the timer is paused and resumed past zero, even if the onExpire reference changes between renders.

Is this the same as react-timer-hook or react-countdown?

No. Those libraries have broader scope (stopwatch + countdown + intervals) and larger bundles. react-drift-timer is a single hook focused on one problem: keeping a long countdown accurate against a wall clock. If you need a stopwatch or multiple timers in one component, use one of those instead.

Does it work with React 17? React 18? React 19?

Yes — all three. The peerDependencies is react: >=17.0.0. The hook only uses useState, useEffect, useRef, and useCallback.

Does it work with Next.js App Router and React Server Components?

Yes. The source has a "use client" directive so it works inside a Server Component tree without manual annotation. Import and use it from any client component.

Does it have any runtime dependencies?

No. The only peerDependency is react. The published bundle ships ESM, CommonJS, and .d.ts types.

Can I use this for a Pomodoro timer?

Yes — it's the same shape. Pass 25 * 60 as initialSeconds, pass your "break starts" callback as onExpire, and use pause / resume for the "pause Pomodoro" button. When the break starts, call the hook again with 5 * 60.

License

MIT © humberrealestate.com

Built and battle-tested on a real-estate exam prep platform where a mis-counted timer would be a bug report. If you find this useful and want to support more open-source extracts like it, you can sponsor on GitHub.

About

Drift-resistant React countdown timer hook. Absolute-time math instead of setInterval accumulation, so a 30-minute timer ends at minute 30 even after the tab backgrounds. Powers humberrealestate.com.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Sponsor this project

  •  

Packages

 
 
 

Contributors