use Graft Counter
A countdown that corrects itself against the clock, not against the ticks.
API
Options
| prop | type | default | |
|---|---|---|---|
duration? | number | 30 | seconds a run lasts |
defaultValue? | number | null | null | seconds for the run on load, when shorter than duration |
startOnLoad? | boolean | false | start counting on mount |
Returns
| prop | type | default | |
|---|---|---|---|
counter? | number | — | seconds left, 0 when idle |
duration? | number | — | the configured duration |
isCounting? | boolean | — | whether a run is in progress |
start? | (seconds?: number) => void | — | starts a run, full duration by default |
reset? | () => void | — | stops and goes back to 0 |
id? | string | — | stable 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.
// -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>
);
};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.
// four seconds left of a ten second wait, counting from the first render
<GraftResend.Provider duration={10} defaultValue={4} startOnLoad>
<ResendControls />
</GraftResend.Provider>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.
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.