Skip to content

useCounter

Edit

Manages a bounded numeric counter with configurable steps.

useCounter.ts
import { useCallback, useEffect, useState } from "react";
import type { Dispatch, SetStateAction } from "react";
export interface UseCounterOptions {
readonly min?: number;
readonly max?: number;
readonly step?: number;
}
export interface UseCounterResult {
readonly count: number;
readonly setCount: Dispatch<SetStateAction<number>>;
readonly increment: (amount?: number) => void;
readonly decrement: (amount?: number) => void;
readonly reset: () => void;
}
function assertFinite(value: number, name: string): void {
if (!Number.isFinite(value)) {
throw new RangeError(`${name} must be a finite number`);
}
}
function clamp(value: number, min: number | undefined, max: number | undefined) {
return Math.min(max ?? Infinity, Math.max(min ?? -Infinity, value));
}
export function useCounter(
initialValue = 0,
options: UseCounterOptions = {},
): UseCounterResult {
const { min, max, step = 1 } = options;
assertFinite(initialValue, "initialValue");
assertFinite(step, "step");
if (step <= 0) throw new RangeError("step must be greater than zero");
if (min !== undefined) assertFinite(min, "min");
if (max !== undefined) assertFinite(max, "max");
if (min !== undefined && max !== undefined && min > max) {
throw new RangeError("min must be less than or equal to max");
}
const boundedInitial = clamp(initialValue, min, max);
const [storedCount, setInternalCount] = useState(boundedInitial);
const count = clamp(storedCount, min, max);
useEffect(() => {
setInternalCount((current) => {
const boundedCurrent = clamp(current, min, max);
return Object.is(current, boundedCurrent)
? current
: boundedCurrent;
});
}, [max, min]);
const setCount = useCallback<Dispatch<SetStateAction<number>>>(
(action) => {
setInternalCount((current) => {
const boundedCurrent = clamp(current, min, max);
const next =
typeof action === "function" ? action(boundedCurrent) : action;
assertFinite(next, "count");
return clamp(next, min, max);
});
},
[max, min],
);
const increment = useCallback(
(amount = step) => {
assertFinite(amount, "amount");
if (amount < 0) {
throw new RangeError("amount must be greater than or equal to zero");
}
setCount((current) => current + amount);
},
[setCount, step],
);
const decrement = useCallback(
(amount = step) => {
assertFinite(amount, "amount");
if (amount < 0) {
throw new RangeError("amount must be greater than or equal to zero");
}
setCount((current) => current - amount);
},
[setCount, step],
);
const reset = useCallback(() => {
setInternalCount(clamp(initialValue, min, max));
}, [initialValue, max, min]);
return { count, setCount, increment, decrement, reset };
}
Terminal
wget -O src/hooks/useCounter.ts https://raw.githubusercontent.com/jrTilak/lazykit/HEAD/registry/react-hooks/useCounter.ts
useCounter.example.tsx
import { useCounter } from "./useCounter";
export function CounterExample() {
const { count, increment, decrement, reset } = useCounter(0, {
min: 0,
max: 20,
step: 2,
});
return (
<div>
<p>Count: {count}</p>
<button type="button" onClick={() => decrement()}>Decrease</button>
<button type="button" onClick={() => increment()}>Increase</button>
<button type="button" onClick={reset}>Reset</button>
</div>
);
}
  • initialValue (number, default: 0) — Initial and reset value.
  • options.min (number, optional) — Inclusive lower bound.
  • options.max (number, optional) — Inclusive upper bound.
  • options.step (number, default: 1) — Finite positive default increment and decrement amount.

All numbers must be finite and min cannot exceed max.

UseCounterResult with count, bounded setCount, increment, decrement, and reset. Passing a finite non-negative amount to increment or decrement overrides step for that call. Use setCount for signed arithmetic. The current count is clamped again when either bound changes.