use Graft Counter

A countdown that corrects itself against the clock, not against the ticks.

API

Options

proptypedefault
duration?number30seconds a run lasts
defaultValue?number | nullnullseconds for the run on load, when shorter than duration
startOnLoad?booleanfalsestart counting on mount

Returns

proptypedefault
counter?numberseconds left, 0 when idle
duration?numberthe configured duration
isCounting?booleanwhether a run is in progress
start?(seconds?: number) => voidstarts a run, full duration by default
reset?() => voidstops and goes back to 0
id?stringstable id

Usage

Seconds left

The shape a resend button wants: one flag to disable it, one number to put in the label. Nothing to schedule, nothing to clear, and no third state to reason about between runs.

tsx
// -constants/graft_resend.ts
export const GraftResend = createGraft({ name: "GraftResend", graft: useGraftCounter });

// -components/resend_form.tsx
export const ResendForm = () => (
  <GraftResend.Provider duration={10}>
    <ResendButton />
  </GraftResend.Provider>
);

// -components/resend_button.tsx
export const ResendButton = () => {
  const { counter, isCounting, start } = GraftResend.use();

  return (
    <Button disabled={isCounting} onClick={start}>
      {isCounting ? `wait ${counter}s` : "resend code"}
    </Button>
  );
};
previewten seconds, cancellable

Counting from the first render

startOnLoad begins a run on mount, for the case where the wait is already underway when the screen appears — a code that has just been sent, a lockout that started elsewhere. Pair it with defaultValue when that first run is shorter than a full one, since only some of it is left.

tsx
// four seconds left of a ten second wait, counting from the first render
<GraftResend.Provider duration={10} defaultValue={4} startOnLoad>
  <ResendControls />
</GraftResend.Provider>
previewalready counting on arrival: 4 seconds left of a 10 second wait

Details

A deadline, not a tally

The state is a moment in time, not a number being decremented. Every tick asks the clock how far that moment still is, so a slow interval, a busy main thread or a backgrounded tab cost you accuracy for one second and nothing more — the count never drifts.

tsx
const remainingFrom = (deadline: number) => Math.max(0, Math.ceil((deadline - Date.now()) / 1000));

Rounding is upward, so a run of ten shows 10 from the start rather than jumping there a moment later. On the tick that lands, counter and isCounting are updated together and the interval is cleared, so nothing renders a zero next to a button that is still disabled.

Idle is zero

counter reads 0 when nothing is running, so a button can be disabled on isCounting alone and the label needs no third state. Calling start mid-run restarts a full duration, which is what a resend button should do; the way to stop without finishing is reset.

Nothing is written down anywhere: the deadline lives in the component, so a reload starts from nothing. If a countdown has to survive that, storing the moment it ends is yours — and start(seconds) is how it comes back, since resuming is starting with what is left. Use startOnLoad when the remainder is known before the first render, and start when it arrives later.