@akhatmirzo/ui

Hooks

@akhatmirzo/hooks — 12 dependency-free React hooks that are safe during SSR.

The logic you keep rewriting inside components: debouncing, localStorage, media queries, keyboard shortcuts, pagination. The package depends only on react (18 or newer), doesn't break on the server and works without @akhatmirzo/ui.

pnpm add @akhatmirzo/hooks
import { useDebounce, useHotkeys, useLocalStorage } from '@akhatmirzo/hooks';
HookWhat it's for
useToggleA true / false state and a toggle
useDebounceDelay a value (search)
useDebouncedCallbackDelay a function (autosave)
useControllableStateControlled and uncontrolled state
useLocalStorageState synced with localStorage
useMediaQueryThe result of a CSS media query
useClickOutsideClicks outside an element
useCopyToClipboardCopy to the clipboard
useIntersectionEntering the viewport (infinite scroll)
useAsyncState of an async function
usePaginationPagination buttons
useHotkeysKeyboard shortcuts

In Next.js

Hooks only run in Client Components — put 'use client'; on the first line of the file.

State

useToggle

function useToggle(
  initial?: boolean,
): readonly [value: boolean, toggle: () => void, setValue: Dispatch<SetStateAction<boolean>>];

A boolean state. toggle is stable (the same on every render), so you can pass it straight to onClick or to other hooks. initial defaults to false.

const [open, toggleOpen, setOpen] = useToggle();

<Button onClick={toggleOpen}>Filters</Button>;
<Button onClick={() => setOpen(false)}>Close</Button>;

useControllableState

function useControllableState<T>(params: {
  value?: T;
  defaultValue?: T;
  onChange?: (value: T) => void;
}): [T, Dispatch<SetStateAction<T>>];

Lets your own component be both controlled (value + onValueChange) and uncontrolled (defaultValue). When value !== undefined, the value comes from the parent; otherwise internal state is used. onChange fires in both modes, and only when the value actually changes. The library's Dialog, Tabs and Select follow the same pattern.

Boshqarilmaydigan (defaultValue=3)

3

Boshqariladigan (0…20)

10
stepper.tsx
interface StepperProps {
  value?: number;
  defaultValue?: number;
  onValueChange?: (value: number) => void;
}

function Stepper({ value, defaultValue = 0, onValueChange }: StepperProps) {
  const [count, setCount] = useControllableState({ value, defaultValue, onChange: onValueChange });
  return <Button onClick={() => setCount((n) => n + 1)}>{count}</Button>;
}

<Stepper defaultValue={3} />; // manages itself
<Stepper value={qty} onValueChange={setQty} />; // the parent is in charge

About the type

With defaultValue, the value type is T. Without it, it's T | undefined.

Debouncing

useDebounce

function useDebounce<T>(value: T, ms?: number): T;

Delays a value: it updates ms milliseconds after the last change (default 300). The classic use is a search field that shouldn't fire a request on every keystroke.

Natija yozishni to'xtatganingizdan 400 ms keyin

Yangilangan
ToshkentSamarqandBuxoroXivaNamanganAndijonNukusTermiz
const [query, setQuery] = useState('');
const debounced = useDebounce(query, 400);

useEffect(() => {
  if (debounced) void api.get('/search', { query: { q: debounced } });
}, [debounced]);

useDebouncedCallback

function useDebouncedCallback<Args extends unknown[]>(
  fn: (...args: Args) => void,
  ms?: number,
): DebouncedFunction<Args>;

interface DebouncedFunction<Args> {
  (...args: Args): void;
  cancel: () => void;
  flush: () => void;
  isPending: () => boolean;
}

Delays a function. Use it when you need to delay an action rather than a value — autosave, for example. It always calls the latest fn (no stale closures), and a pending call is cancelled on unmount. ms defaults to 300.

Avtomatik saqlash: yozishni to'xtatgach 800 ms o'tib

    const save = useDebouncedCallback((text: string) => api.patch('/notes/1', { text }), 800);
    
    <Textarea onChange={(e) => save(e.target.value)} onBlur={save.flush} />;
    MethodWhat it does
    cancel()Drops the pending call
    flush()Runs the pending call right away
    isPending()Whether a call is pending

    Browser

    useLocalStorage

    function useLocalStorage<T>(
      key: string,
      initial: T | (() => T),
    ): [value: T, setValue: (value: T | ((prev: T) => T)) => void, remove: () => void];

    State synced with localStorage. The value is stored as JSON and stays in sync: other hooks with the same key on the page and other browser tabs update too. remove deletes the key and the value falls back to initial.

    0

    Boshqa komponent ko'rayotgan qiymat: 0

    Sahifani boshqa tabda oching — qiymat sinxron o'zgaradi

    const [recent, setRecent, clearRecent] = useLocalStorage<string[]>('recent-searches', []);
    
    const remember = (query: string) =>
      setRecent((list) => [query, ...list.filter((q) => q !== query)].slice(0, 5));

    It never throws: on the server you get initial; if storage is blocked or full, the value lives in page memory (the state keeps working); broken JSON gives initial.

    Don't mix it with createStorage

    createStorage (@akhatmirzo/core) wraps values in its own envelope. Don't use the same key with both.

    useMediaQuery

    function useMediaQuery(query: string, defaultValue?: boolean): boolean;

    Whether a CSS media query matches — it updates when the window size or an OS setting changes. On the server, and where matchMedia doesn't exist, it returns defaultValue (default false).

    ≥ 640px≥ 1024pxTizim: qorong'i mavzuKam animatsiyaSichqoncha (hover)
    const isMobile = useMediaQuery('(max-width: 639px)');
    const prefersDark = useMediaQuery('(prefers-color-scheme: dark)');
    const reducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
    
    return isMobile ? <Drawer>…</Drawer> : <Dialog>…</Dialog>;

    useClickOutside

    function useClickOutside(
      refs: RefObject<Element | null> | ReadonlyArray<RefObject<Element | null>>,
      handler: (event: PointerEvent) => void,
      enabled?: boolean,
    ): void;

    Calls handler on a pointerdown outside the given element(s). You can pass several refs — for example the button and the panel, so clicking the button itself doesn't count as "outside". enabled (default true) lets you stop listening while the panel is closed.

    const triggerRef = useRef<HTMLButtonElement>(null);
    const panelRef = useRef<HTMLDivElement>(null);
    
    useClickOutside([triggerRef, panelRef], () => setOpen(false), open);

    Clicks on elements that were removed from the DOM (say, a menu item that disappears when clicked) are ignored.

    useCopyToClipboard

    function useCopyToClipboard(resetMs?: number): {
      copied: boolean;
      copy: (text: string) => Promise<boolean>;
    };

    Copies text to the clipboard. copy resolves to whether it worked and never throws. copied stays true for resetMs (default 2000) and then resets on its own; 0 means it never resets. When the Clipboard API isn't available, it falls back to execCommand('copy').

    pnpm add @akhatmirzo/hooks
    const { copied, copy } = useCopyToClipboard();
    
    <Button onClick={() => copy(promoCode)}>{copied ? 'Copied' : 'Copy'}</Button>;

    useIntersection

    function useIntersection<T extends Element = Element>(
      options?: IntersectionObserverInit & { enabled?: boolean },
    ): [ref: (node: T | null) => void, entry: IntersectionObserverEntry | null];

    Tracks when an element enters the viewport — for infinite scroll and lazy loading. It returns a callback ref, so the element is observed even if it renders later or conditionally. enabled: false stops observing (for example, when there are no more pages). Where IntersectionObserver doesn't exist, entry is null.

    • Buyurtma #1000
    • Buyurtma #1001
    • Buyurtma #1002
    • Buyurtma #1003
    • Buyurtma #1004
    • Buyurtma #1005
    • Buyurtma #1006
    • Buyurtma #1007
    • Buyurtma #1008
    • Buyurtma #1009
    • Buyurtma #1010
    • Buyurtma #1011
    Yuklanmoqda Yuklanmoqda…
    const [sentinelRef, entry] = useIntersection<HTMLDivElement>({
      rootMargin: '200px',
      enabled: hasMore,
    });
    
    useEffect(() => {
      if (entry?.isIntersecting) loadMore();
    }, [entry, items.length]);
    
    return (
      <>
        {items.map(renderItem)}
        <div ref={sentinelRef} />
      </>
    );

    Why items.length is a dependency

    While the sentinel stays visible, entry doesn't change. Adding the list length to the dependencies re-checks it after each new page loads.

    Data

    useAsync

    function useAsync<T, Args extends unknown[] = []>(
      fn: (...args: Args) => Promise<T>,
      deps?: DependencyList,
      options?: { immediate?: boolean },
    ): {
      status: 'idle' | 'loading' | 'success' | 'error';
      data: T | undefined;
      error: unknown;
      run: (...args: Args) => Promise<T | undefined>;
    };

    The state of an async function. With immediate: true (the default) it runs on mount and whenever deps change; with immediate: false it only runs through run(). Only the latest call wins: if the user clicks quickly, an old response never overwrites a newer one. Responses that arrive after unmount are ignored.

    idle
    // Loads by itself whenever the page changes
    const { status, data, error } = useAsync(
      () => api.get<Order[]>('/orders', { query: { page } }),
      [page],
    );
    
    // Only on click
    const { run, status: saving } = useAsync((form: OrderForm) => api.post('/orders', form), [], {
      immediate: false,
    });
    • data is the last successful result. It's kept during reloads and errors, so a list doesn't go blank while loading.
    • run resolves to undefined for stale or failed calls and never throws; the error goes to error.

    usePagination

    function usePagination(params: {
      total: number;
      pageSize: number;
      page: number;
      siblings?: number;
    }): {
      pages: (number | 'ellipsis')[];
      pageCount: number;
      page: number;
      hasPrev: boolean;
      hasNext: boolean;
      from: number;
      to: number;
    };

    The list of pagination buttons: 1 … 4 5 6 … 20. The number of buttons is stable (siblings * 2 + 5), so the bar doesn't jump around as the page changes. An ellipsis only appears when it hides 2 or more pages.

    41–50 / 200 ta yozuv

    usePagination({ total: 200, pageSize: 10, page: 5 });
    // { pages: [1, 'ellipsis', 4, 5, 6, 'ellipsis', 20], pageCount: 20, page: 5,
    //   hasPrev: true, hasNext: true, from: 41, to: 50 }
    
    usePagination({ total: 200, pageSize: 10, page: 1 }).pages; // [1, 2, 3, 4, 5, 'ellipsis', 20]
    usePagination({ total: 50, pageSize: 10, page: 2 }).pages; // [1, 2, 3, 4, 5]
    usePagination({ total: 95, pageSize: 10, page: 99 }).page; // 10 — clamped
    PropTypeDefaultDescription
    totalrequirednumber—Total number of records
    pageSizerequirednumber—Records per page
    pagerequirednumber—Current page, starting at 1. Clamped when out of range
    siblingsnumber1Pages shown on each side of the current one

    from / to are the positions of the first and last record on the current page ("41–50 / 200"). For an empty list, from is 0. If you need the same calculation outside React (on the server, say), there's a pure function getPagination with the same parameters.

    Keyboard

    useHotkeys

    function useHotkeys(
      keys: string,
      handler: (event: KeyboardEvent) => void,
      options?: { enabled?: boolean; preventDefault?: boolean; enableOnInputs?: boolean },
    ): void;

    Global keyboard shortcuts. mod is ⌘ on macOS and Ctrl everywhere else. Separate several combos with commas: 'mod+k, /'. By default, shortcuts don't fire inside inputs, textareas, selects and contenteditable elements, so they never get in the way of typing.

    AltK?G

    Yuqoridagi tugmalardan birini bosing

    useHotkeys('mod+k', () => setPaletteOpen(true));
    useHotkeys('mod+s', () => save()); // the browser's "Save page" dialog doesn't open
    useHotkeys('esc', close, { enabled: open, enableOnInputs: true });
    PropTypeDefaultDescription
    enabledbooleantruefalse stops listening (for example while a panel is closed)
    preventDefaultbooleantrueStops the browser's default action on a match
    enableOnInputsbooleanfalseAlso fire inside inputs, textareas, selects and contenteditable

    Writing combos:

    • Modifiers: mod, ctrl, meta (cmd), alt (option), shift.
    • Key names: letters and digits, esc, enter, space, up / down / left / right, del, comma, plus, and other KeyboardEvent.key values (tab, f1 ...).
    • Symbols: characters like '?' or '/' work even though they're typed with Shift — no need to write shift.
    • On macOS, alt changes the letter (Alt+K → ˚), but alt+k still works because the physical key is checked.

    To show a shortcut next to a button, use Kbd: <Kbd keys={['mod', 'K']} />.

    matchHotkey and isApplePlatform

    function matchHotkey(event: KeyboardEvent, keys: string): boolean;
    function isApplePlatform(): boolean; // false on the server

    The check inside useHotkeys, on its own — for testing a shortcut in your own onKeyDown.

    <Input
      onKeyDown={(e) => {
        if (matchHotkey(e.nativeEvent, 'mod+enter')) submit();
      }}
    />

    On this page