Fiber UI LogoFiberUI

useCounter

Manage counter state with increment, decrement, and reset functionality.

A simple yet powerful hook for managing numeric state. It handles common counter operations like incrementing, decrementing, resetting, and step-based updates.

Source Code

View the full hook implementation in the Hook Source Code section below.

Basic Counter

A simple counter with increment, decrement, and reset functionality.

0
"use client";

import { useCounter } from "@repo/hooks/utility/use-counter";
import { Button } from "@repo/ui/components/button";
import { Plus, Minus, RotateCcw } from "lucide-react";

export const Example1 = () => {
    const { count, increment, decrement, reset } = useCounter(0, { step: 1 });

    return (
        <div className="flex flex-col items-center gap-4 rounded-lg border p-6">
            <div className="text-4xl font-bold tabular-nums">{count}</div>

            <div className="flex gap-2">
                <Button
                    variant="outline"
                    size="icon"
                    onClick={decrement}
                    aria-label="Decrement"
                >
                    <Minus className="h-4 w-4" />
                </Button>

                <Button
                    variant="outline"
                    size="icon"
                    onClick={reset}
                    aria-label="Reset"
                >
                    <RotateCcw className="h-4 w-4" />
                </Button>

                <Button
                    variant="outline"
                    size="icon"
                    onClick={increment}
                    aria-label="Increment"
                >
                    <Plus className="h-4 w-4" />
                </Button>
            </div>
        </div>
    );
};

Quantity Selector

Using limits to create a robust quantity selector for e-commerce.

Coffee Beans

$12.00 / bag

1
Total: $12.00

Max 10 items

"use client";

import { useCounter } from "@repo/hooks/utility/use-counter";
import { Button } from "@repo/ui/components/button";
import { ShoppingCart, Plus, Minus } from "lucide-react";

export const Example2 = () => {
    // Quantity selector usually starts at 1, step 1, min 1, max 10
    const { count, increment, decrement, canIncrement, canDecrement } =
        useCounter(1, {
            min: 1,
            max: 10,
        });

    return (
        <div className="w-full max-w-sm rounded-lg border p-4 shadow-sm">
            <div className="mb-4 flex items-center justify-between">
                <div>
                    <h3 className="font-medium">Coffee Beans</h3>
                    <p className="text-muted-foreground text-sm">
                        $12.00 / bag
                    </p>
                </div>
                <div className="bg-primary/10 text-primary rounded-full p-2">
                    <ShoppingCart className="h-5 w-5" />
                </div>
            </div>

            <div className="flex items-center justify-between border-t pt-4">
                <div className="flex items-center gap-3">
                    <Button
                        variant="outline"
                        size="icon"
                        className="h-8 w-8"
                        onClick={decrement}
                        isDisabled={!canDecrement}
                    >
                        <Minus className="h-3 w-3" />
                    </Button>

                    <span className="w-8 text-center font-medium tabular-nums">
                        {count}
                    </span>

                    <Button
                        variant="outline"
                        size="icon"
                        className="h-8 w-8"
                        onClick={increment}
                        isDisabled={!canIncrement}
                    >
                        <Plus className="h-3 w-3" />
                    </Button>
                </div>

                <div className="font-medium">
                    Total: ${(count * 12).toFixed(2)}
                </div>
            </div>

            <p className="text-muted-foreground mt-2 text-right text-xs">
                Max 10 items
            </p>
        </div>
    );
};

Pagination Controls

Implementing pagination logic using incrementBy and decrementBy.

Page 1 of 20

Item 1
Item 2
Item 3
Item 4
Item 5
1
"use client";

import { useCounter } from "@repo/hooks/utility/use-counter";
import { Button } from "@repo/ui/components/button";
import {
    ChevronLeft,
    ChevronRight,
    ChevronsLeft,
    ChevronsRight,
} from "lucide-react";

export const Example3 = () => {
    // Pagination: Start at page 1, total pages = 20
    const totalPages = 20;
    const {
        count: page,
        increment,
        decrement,
        reset: goToFirst,
        incrementBy,
    } = useCounter(1, { step: 1 });

    // Helpers to keep within bounds
    const nextPage = () => {
        if (page < totalPages) increment();
    };

    const prevPage = () => {
        if (page > 1) decrement();
    };

    const goToLast = () => {
        const diff = totalPages - page;
        if (diff > 0) incrementBy(diff);
    };

    // Simulating fetching data for current page
    const items = Array.from({ length: 5 }, (_, i) => ({
        id: (page - 1) * 5 + i + 1,
        name: `Item ${(page - 1) * 5 + i + 1}`,
    }));

    return (
        <div className="w-full max-w-md space-y-4">
            {/* Limit simulation */}
            <div className="rounded-lg border bg-zinc-50 p-4 dark:bg-zinc-900">
                <h4 className="mb-2 text-sm font-medium text-zinc-500">
                    Page {page} of {totalPages}
                </h4>
                <div className="space-y-1">
                    {items.map((item) => (
                        <div
                            key={item.id}
                            className="rounded bg-white p-2 text-sm shadow-sm dark:bg-zinc-800"
                        >
                            {item.name}
                        </div>
                    ))}
                </div>
            </div>

            {/* Controls */}
            <div className="flex items-center justify-center gap-1">
                <Button
                    variant="ghost"
                    size="icon"
                    onClick={goToFirst}
                    isDisabled={page === 1}
                    aria-label="First page"
                >
                    <ChevronsLeft className="h-4 w-4" />
                </Button>

                <Button
                    variant="outline"
                    size="icon"
                    onClick={prevPage}
                    isDisabled={page === 1}
                    aria-label="Previous page"
                >
                    <ChevronLeft className="h-4 w-4" />
                </Button>

                <div className="mx-2 min-w-[3ch] text-center text-sm font-medium">
                    {page}
                </div>

                <Button
                    variant="outline"
                    size="icon"
                    onClick={nextPage}
                    isDisabled={page === totalPages}
                    aria-label="Next page"
                >
                    <ChevronRight className="h-4 w-4" />
                </Button>

                <Button
                    variant="ghost"
                    size="icon"
                    onClick={goToLast}
                    isDisabled={page === totalPages}
                    aria-label="Last page"
                >
                    <ChevronsRight className="h-4 w-4" />
                </Button>
            </div>
        </div>
    );
};

API Reference

Hook Signature

function useCounter(
    initialCount?: number,
    options?: UseCounterOpts,
): UseCounterReturn;

UseCounterOpts

NameTypeDefaultDescription
initialCountnumber0The starting value of the counter.
stepnumber1The amount to change the count by.
minnumberundefinedMinimum value limit.
maxnumberundefinedMaximum value limit.

UseCounterReturn

NameTypeDescription
countnumberThe current value of the counter.
increment() => voidIncreases the count by the step value.
decrement() => voidDecreases the count by the step value.
reset() => voidResets the count to the initial value.
setCount(value: number) => voidSets the counter to a specific value.
incrementBy(value: number) => voidIncreases the count by the specified value.
decrementBy(value: number) => voidDecreases the count by the specified value.
canIncrementbooleanWhether the counter can be incremented (not at max).
canDecrementbooleanWhether the counter can be decremented (not at min).

Hook Source Code

import { useState, useCallback } from "react";

/**
 * Options for the useCounter hook
 */
export interface UseCounterOpts {
    /** Default step value for increment/decrement (default: 1) */
    step?: number;
    /** Minimum value limit */
    min?: number;
    /** Maximum value limit */
    max?: number;
}

/**
 * Return type for the useCounter hook
 */
export interface UseCounterReturn {
    /** Current counter value */
    count: number;
    /** Reset counter to initial value */
    reset: () => void;
    /** Increment counter by step */
    increment: () => void;
    /** Decrement counter by step */
    decrement: () => void;
    /** Set counter to a specific value */
    setCount: (value: number) => void;
    /** Increment counter by specific value */
    incrementBy: (value: number) => void;
    /** Decrement counter by specific value */
    decrementBy: (value: number) => void;
    /** Whether count can be incremented (not at max) */
    canIncrement: boolean;
    /** Whether count can be decremented (not at min) */
    canDecrement: boolean;
}

/**
 * A React hook for managing a counter state with increment, decrement, and reset capabilities.
 *
 * @param initialCount - The initial value of the counter (default: 0)
 * @param options - Options object for step, min, and max limits
 * @returns UseCounterReturn object with count and control methods
 */

export const useCounter = (
    initialCount: number = 0,
    options: UseCounterOpts = {},
): UseCounterReturn => {
    const { step = 1, min, max } = options;

    const [count, setInternalCount] = useState(() => {
        let val = initialCount;
        if (min !== undefined) val = Math.max(val, min);
        if (max !== undefined) val = Math.min(val, max);
        return val;
    });

    const increment = useCallback(() => {
        setInternalCount((c) => {
            const next = c + step;
            if (max !== undefined && next > max) return max;
            if (min !== undefined && next < min) return min;
            return next;
        });
    }, [step, max, min]);

    const decrement = useCallback(() => {
        setInternalCount((c) => {
            const next = c - step;
            if (min !== undefined && next < min) return min;
            if (max !== undefined && next > max) return max;
            return next;
        });
    }, [step, min, max]);

    const setCount = useCallback(
        (val: number) => {
            setInternalCount(() => {
                let next = val;
                if (min !== undefined) next = Math.max(next, min);
                if (max !== undefined) next = Math.min(next, max);
                return next;
            });
        },
        [min, max],
    );

    const incrementBy = useCallback(
        (val: number) => {
            setInternalCount((c) => {
                const next = c + val;
                if (max !== undefined && next > max) return max;
                if (min !== undefined && next < min) return min;
                return next;
            });
        },
        [max, min],
    );

    const decrementBy = useCallback(
        (val: number) => {
            setInternalCount((c) => {
                const next = c - val;
                if (min !== undefined && next < min) return min;
                if (max !== undefined && next > max) return max;
                return next;
            });
        },
        [min, max],
    );

    const reset = useCallback(() => {
        let val = initialCount;
        if (min !== undefined) val = Math.max(val, min);
        if (max !== undefined) val = Math.min(val, max);
        setInternalCount(val);
    }, [initialCount, min, max]);

    return {
        count,
        setCount,
        reset,
        increment,
        decrement,
        incrementBy,
        decrementBy,
        canIncrement: max === undefined || count < max,
        canDecrement: min === undefined || count > min,
    };
};