useCountdown
EditRuns a controllable count-up or countdown with clamped steps and optional looping.
import { useCallback, useEffect, useLayoutEffect, useRef, useState,} from "react";
export type UseCountdownOptions = { from: number; to?: number; step?: number; intervalMs?: number; autoStart?: boolean; loop?: boolean; onChange?: (this: void, value: number) => void; onComplete?: (this: void) => void;};
export type UseCountdownReturn = { value: number; isRunning: boolean; start: () => void; pause: () => void; reset: () => void;};
const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
/** Runs a controllable count-up or countdown between two finite values. */export const useCountdown = ({ from, to = 0, step = 1, intervalMs = 1_000, autoStart = false, loop = false, onChange, onComplete,}: UseCountdownOptions): UseCountdownReturn => { if (!Number.isFinite(from) || !Number.isFinite(to)) { throw new RangeError("from and to must be finite numbers"); } if (!Number.isFinite(step) || step <= 0) { throw new RangeError("step must be a finite, positive number"); } if (!Number.isFinite(intervalMs) || intervalMs < 0) { throw new RangeError("intervalMs must be a finite, non-negative number"); }
const [value, setValue] = useState(from); const [isRunning, setIsRunning] = useState(autoStart && from !== to); const valueRef = useRef(from); const fromRef = useRef(from); const toRef = useRef(to); const stepRef = useRef(step); const loopRef = useRef(loop); const onChangeRef = useRef(onChange); const onCompleteRef = useRef(onComplete);
useIsomorphicLayoutEffect(() => { fromRef.current = from; toRef.current = to; stepRef.current = step; loopRef.current = loop; onChangeRef.current = onChange; onCompleteRef.current = onComplete; }, [from, loop, onChange, onComplete, step, to]);
useIsomorphicLayoutEffect(() => { valueRef.current = from; setValue(from); setIsRunning(autoStart && from !== to); }, [from, to]);
useIsomorphicLayoutEffect(() => { if (!autoStart || fromRef.current === toRef.current) { setIsRunning(false); return; }
if (valueRef.current === toRef.current) { valueRef.current = fromRef.current; setValue(fromRef.current); } setIsRunning(true); }, [autoStart]);
useEffect(() => { if (!isRunning) return;
const timer = setInterval(() => { const current = valueRef.current; const target = toRef.current;
if (current === target) { if (!loopRef.current) { setIsRunning(false); return; }
const restarted = fromRef.current; valueRef.current = restarted; setValue(restarted); if (onChangeRef.current !== undefined) { Reflect.apply(onChangeRef.current, undefined, [restarted]); } return; }
const direction = fromRef.current < target ? 1 : -1; const candidate = current + direction * stepRef.current; const next = direction === 1 ? Math.min(candidate, target) : Math.max(candidate, target);
valueRef.current = next; setValue(next); if (onChangeRef.current !== undefined) { Reflect.apply(onChangeRef.current, undefined, [next]); }
if (next === target) { if (!loopRef.current) setIsRunning(false); if (onCompleteRef.current !== undefined) { Reflect.apply(onCompleteRef.current, undefined, []); } } }, intervalMs);
return () => { clearInterval(timer); }; }, [intervalMs, isRunning]);
const start = useCallback(() => { if (fromRef.current === toRef.current) return; if (valueRef.current === toRef.current) { valueRef.current = fromRef.current; setValue(fromRef.current); } setIsRunning(true); }, []);
const pause = useCallback(() => { setIsRunning(false); }, []);
const reset = useCallback(() => { valueRef.current = fromRef.current; setValue(fromRef.current); setIsRunning(false); }, []);
return { value, isRunning, start, pause, reset };};Download
Section titled “Download”wget -O src/hooks/useCountdown.ts https://raw.githubusercontent.com/jrTilak/lazykit/HEAD/registry/react-hooks/useCountdown.tstemp_dir="$(mktemp -d)" && bunx degit jrTilak/lazykit/registry/react-hooks "$temp_dir" && mv "$temp_dir/useCountdown.ts" src/hooks/useCountdown.ts && rm -r "$temp_dir"Examples
Section titled “Examples”import { useCountdown } from "./useCountdown";
export const ResendCountdown = () => { const countdown = useCountdown({ from: 30, to: 0, autoStart: true });
return ( <button type="button" disabled={countdown.isRunning} onClick={countdown.start} > {countdown.isRunning ? `Resend in ${countdown.value}s` : "Resend code"} </button> );};from(number) — Finite starting value.to(number, default:0) — Finite terminal value. Direction is inferred fromfromandto.step(number, default:1) — Finite, positive amount per tick. Overshoot is clamped toto.intervalMs(number, default:1000) — Finite, non-negative tick interval.autoStart(boolean, default:false) — Starts or resumes automatically when the endpoints differ. Changing it tofalsepauses without resetting the current value.loop(boolean, default:false) — Keeps the terminal value for one interval, then restarts atfrom.onChange((value: number) => void) — Called after every published tick or loop restart.onComplete(() => void) — Called whenever a run reachesto.
Returns
Section titled “Returns”An object containing value, isRunning, start(), pause(), and reset(). Calling start() at the terminal value begins a fresh run. Changing from or to resets the value to from.