Hooks
@akhatmirzo/hooks — 27 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/hooksimport { useDebounce, useHotkeys, useLocalStorage } from '@akhatmirzo/hooks';| Hook | What it's for |
|---|---|
useToggle | A true / false state and a toggle |
useDebounce | Delay a value (search) |
useDebouncedCallback | Delay a function (autosave) |
useControllableState | Controlled and uncontrolled state |
useLocalStorage | State synced with localStorage |
useMediaQuery | The result of a CSS media query |
useClickOutside | Clicks outside an element |
useCopyToClipboard | Copy to the clipboard |
useIntersection | Entering the viewport (infinite scroll) |
useEventListener | A typed event listener |
useResizeObserver | Watch an element's size |
useScrollLock | Lock page scroll (modals) |
useFocusTrap | Keep focus inside a container |
useLongPress | Long press |
useDocumentVisibility | Whether the tab is visible |
useIsClient | true after hydration |
useAsync | State of an async function |
usePagination | Pagination buttons |
useHotkeys | Keyboard shortcuts |
useInterval | An interval (null pauses it) |
useTimeout | A one-off timer |
useCountdown | A countdown (SMS code) |
useIdle | User inactivity |
useStep | Multi-step flow (wizard) |
useInfiniteScroll | Infinite scroll |
usePrevious | The previous value |
useOnline | Network status |
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.
'use client';
import { useToggle } from '@akhatmirzo/hooks';
import { Button, Switch } from '@akhatmirzo/ui';
export default function HooksToggle() {
const [notify, toggleNotify, setNotify] = useToggle(true);
return (
<div className="flex flex-col items-center gap-4">
<Switch
checked={notify}
onCheckedChange={setNotify}
label="SMS xabarnomalar"
description={notify ? 'Yoqilgan' : "O'chirilgan"}
/>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={toggleNotify}>
Almashtirish
</Button>
<Button variant="ghost" onClick={() => setNotify(false)}>
O'chirish
</Button>
</div>
</div>
);
}
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)
Boshqariladigan (0…20)
'use client';
import { useControllableState } from '@akhatmirzo/hooks';
import { Button } from '@akhatmirzo/ui';
import { useState } from 'react';
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 (
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => setCount((n) => n - 1)}>
−
</Button>
<span className="min-w-8 text-center font-semibold text-text tabular-nums">{count}</span>
<Button size="sm" variant="outline" onClick={() => setCount((n) => n + 1)}>
+
</Button>
</div>
);
}
export default function HooksControllableState() {
const [value, setValue] = useState(10);
return (
<div className="grid w-full max-w-md gap-6 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<p className="text-xs font-medium text-text-muted">Boshqarilmaydigan (defaultValue=3)</p>
<Stepper defaultValue={3} />
</div>
<div className="flex flex-col gap-2">
<p className="text-xs font-medium text-text-muted">Boshqariladigan (0…20)</p>
<Stepper value={value} onValueChange={(v) => setValue(Math.min(20, Math.max(0, v)))} />
</div>
</div>
);
}
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 chargeAbout 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
'use client';
import { useDebounce } from '@akhatmirzo/hooks';
import { Badge, Field, Input } from '@akhatmirzo/ui';
import { useState } from 'react';
const CITIES = [
'Toshkent',
'Samarqand',
'Buxoro',
'Xiva',
'Namangan',
'Andijon',
'Nukus',
'Termiz',
];
export default function HooksDebounce() {
const [query, setQuery] = useState('');
const debounced = useDebounce(query, 400);
const results = CITIES.filter((c) => c.toLowerCase().includes(debounced.trim().toLowerCase()));
return (
<div className="flex w-full max-w-sm flex-col gap-3">
<Field label="Shahar qidirish" description="Natija yozishni to'xtatganingizdan 400 ms keyin">
<Input value={query} onChange={(e) => setQuery(e.target.value)} clearable />
</Field>
<Badge color={query === debounced ? 'success' : 'warning'} dot className="self-start">
{query === debounced ? 'Yangilangan' : 'Kutilmoqda…'}
</Badge>
<div className="flex flex-wrap gap-2">
{results.map((city) => (
<Badge key={city} variant="outline">
{city}
</Badge>
))}
{results.length === 0 && (
<span className="text-sm text-text-muted">Hech narsa topilmadi</span>
)}
</div>
</div>
);
}
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
'use client';
import { useDebouncedCallback } from '@akhatmirzo/hooks';
import { Button, Field, Textarea } from '@akhatmirzo/ui';
import { useState } from 'react';
export default function HooksDebouncedCallback() {
const [text, setText] = useState('');
const [saved, setSaved] = useState<string[]>([]);
const save = useDebouncedCallback((value: string) => {
setSaved((list) => [`${value.length} belgi saqlandi`, ...list].slice(0, 3));
}, 800);
return (
<div className="flex w-full max-w-sm flex-col gap-3">
<Field label="Izoh" description="Avtomatik saqlash: yozishni to'xtatgach 800 ms o'tib">
<Textarea
value={text}
onChange={(e) => {
setText(e.target.value);
save(e.target.value);
}}
minRows={3}
/>
</Field>
<Button size="sm" variant="outline" className="self-start" onClick={save.flush}>
Hozir saqlash
</Button>
<ul className="text-sm text-text-muted">
{saved.map((line, i) => (
<li key={i}>{line}</li>
))}
</ul>
</div>
);
}
const save = useDebouncedCallback((text: string) => api.patch('/notes/1', { text }), 800);
<Textarea onChange={(e) => save(e.target.value)} onBlur={save.flush} />;| Method | What 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.
Boshqa komponent ko'rayotgan qiymat: 0
Sahifani boshqa tabda oching — qiymat sinxron o'zgaradi
'use client';
import { useLocalStorage } from '@akhatmirzo/hooks';
import { Button } from '@akhatmirzo/ui';
function Mirror() {
const [count] = useLocalStorage('docs-demo:counter', 0);
return (
<p className="text-sm text-text-muted">
Boshqa komponent ko'rayotgan qiymat: <strong className="text-text">{count}</strong>
</p>
);
}
export default function HooksLocalStorage() {
const [count, setCount, remove] = useLocalStorage('docs-demo:counter', 0);
return (
<div className="flex flex-col items-center gap-3">
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => setCount((n) => n - 1)}>
−
</Button>
<span className="min-w-12 text-center text-2xl font-semibold text-text tabular-nums">
{count}
</span>
<Button variant="outline" onClick={() => setCount((n) => n + 1)}>
+
</Button>
<Button variant="ghost" onClick={remove}>
O'chirish
</Button>
</div>
<Mirror />
<p className="text-xs text-text-faint">
Sahifani boshqa tabda oching — qiymat sinxron o'zgaradi
</p>
</div>
);
}
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).
'use client';
import { useMediaQuery } from '@akhatmirzo/hooks';
import { Badge } from '@akhatmirzo/ui';
export default function HooksMediaQuery() {
const queries = [
{ label: '≥ 640px', on: useMediaQuery('(min-width: 640px)') },
{ label: '≥ 1024px', on: useMediaQuery('(min-width: 1024px)') },
{ label: "Tizim: qorong'i mavzu", on: useMediaQuery('(prefers-color-scheme: dark)') },
{ label: 'Kam animatsiya', on: useMediaQuery('(prefers-reduced-motion: reduce)') },
{ label: 'Sichqoncha (hover)', on: useMediaQuery('(hover: hover)') },
];
return (
<div className="flex flex-wrap justify-center gap-2">
{queries.map(({ label, on }) => (
<Badge key={label} color={on ? 'success' : 'neutral'} variant={on ? 'soft' : 'outline'} dot>
{label}
</Badge>
))}
</div>
);
}
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.
'use client';
import { useClickOutside, useHotkeys, useToggle } from '@akhatmirzo/hooks';
import { Button } from '@akhatmirzo/ui';
import { useRef } from 'react';
export default function HooksClickOutside() {
const [open, toggle, setOpen] = useToggle(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
useClickOutside([triggerRef, panelRef], () => setOpen(false), open);
useHotkeys('esc', () => setOpen(false), { enabled: open });
return (
<div className="relative flex flex-col items-center gap-2">
<Button ref={triggerRef} variant="outline" onClick={toggle} aria-expanded={open}>
Filtrlar
</Button>
{open && (
<div
ref={panelRef}
className="w-64 rounded-xl border border-border bg-surface-raised p-4 text-sm text-text shadow-lg"
>
Panel tashqarisiga bosing yoki <kbd className="font-mono">Esc</kbd> ni bosing — yopiladi.
</div>
)}
</div>
);
}
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'use client';
import { useCopyToClipboard } from '@akhatmirzo/hooks';
import { Button } from '@akhatmirzo/ui';
import { Check, Copy } from 'lucide-react';
export default function HooksCopy() {
const { copied, copy } = useCopyToClipboard();
const command = 'pnpm add @akhatmirzo/hooks';
return (
<div className="flex flex-wrap items-center gap-3">
<code className="rounded-lg border border-border bg-surface-muted px-3 py-2 font-mono text-sm text-text">
{command}
</code>
<Button
variant={copied ? 'soft' : 'outline'}
leftIcon={copied ? <Check /> : <Copy />}
onClick={() => void copy(command)}
>
{copied ? 'Nusxalandi' : 'Nusxa olish'}
</Button>
</div>
);
}
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
'use client';
import { useIntersection } from '@akhatmirzo/hooks';
import { Spinner } from '@akhatmirzo/ui';
import { useEffect, useState } from 'react';
const MAX = 60;
export default function HooksIntersection() {
const [count, setCount] = useState(12);
const done = count >= MAX;
const [sentinelRef, entry] = useIntersection<HTMLDivElement>({
rootMargin: '40px',
enabled: !done,
});
const visible = entry?.isIntersecting ?? false;
useEffect(() => {
if (!visible || done) return;
// Haqiqiy ilovada — keyingi sahifani serverdan yuklash
const timer = setTimeout(() => setCount((n) => Math.min(MAX, n + 12)), 500);
return () => clearTimeout(timer);
}, [visible, done, count]);
return (
<div className="h-64 w-full max-w-sm overflow-auto rounded-xl border border-border bg-surface">
<ul className="divide-y divide-border text-sm text-text">
{Array.from({ length: count }, (_, i) => (
<li key={i} className="px-4 py-2">
Buyurtma #{1000 + i}
</li>
))}
</ul>
<div
ref={sentinelRef}
className="flex items-center justify-center gap-2 p-3 text-sm text-text-muted"
>
{done ? (
'Hammasi yuklandi'
) : (
<>
<Spinner size="sm" /> Yuklanmoqda…
</>
)}
</div>
</div>
);
}
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.
DOM and events
DOM hooks accept a RefObject, the element itself or null (null or an empty ref does
nothing). They touch window / document only inside effects, so they're safe on the server.
The callbacks you pass are kept in a "latest value" ref — passing a new function on every render
doesn't resubscribe.
useEventListener
function useEventListener<K extends keyof WindowEventMap>(
type: K,
handler: (event: WindowEventMap[K]) => void,
options?: UseEventListenerOptions,
): void;
function useEventListener(
target: Window | Document | HTMLElement | RefObject<HTMLElement | null> | null | undefined,
type: string, // typed per target: WindowEventMap / DocumentEventMap / HTMLElementEventMap
handler: (event: Event) => void,
options?: UseEventListenerOptions,
): void;
interface UseEventListenerOptions extends AddEventListenerOptions {
enabled?: boolean;
}A typed event listener: the event type is inferred from the target and the event name. An
undefined target means window; null means don't listen. The short form
useEventListener('resize', fn) targets window without touching it during render — the
easiest option for SSR. enabled: false pauses the listener; capture, passive and once are
passed to addEventListener.
Oyna o'lchamini o'zgartiring yoki istalgan tugmani bosing
'use client';
import { useEventListener } from '@akhatmirzo/hooks';
import { Badge, Kbd } from '@akhatmirzo/ui';
import { useState } from 'react';
export default function HooksEventListener() {
const [width, setWidth] = useState<number | null>(null);
const [lastKey, setLastKey] = useState<string | null>(null);
// Qisqa shakl — window uchun, SSR-xavfsiz
useEventListener('resize', () => setWidth(window.innerWidth));
useEventListener('keydown', (event) => setLastKey(event.key));
return (
<div className="flex flex-col items-center gap-3 text-sm text-text">
<p className="text-text-muted">Oyna o'lchamini o'zgartiring yoki istalgan tugmani bosing</p>
<div className="flex flex-wrap items-center justify-center gap-2">
<Badge variant="outline">Kenglik: {width === null ? '—' : `${width}px`}</Badge>
<span className="flex items-center gap-1">
Oxirgi tugma: {lastKey ? <Kbd>{lastKey === ' ' ? 'Space' : lastKey}</Kbd> : '—'}
</span>
</div>
</div>
);
}
useEventListener('resize', () => setWidth(window.innerWidth));
useEventListener(document, 'visibilitychange', onVisibility);
const buttonRef = useRef<HTMLButtonElement>(null);
useEventListener(buttonRef, 'pointerenter', () => prefetch('/orders'));
useEventListener('scroll', onScroll, { passive: true, enabled: open });useResizeObserver
function useResizeObserver<T extends Element = Element>(
target: RefObject<T | null> | T | null,
options?: { box?: ResizeObserverBoxOptions; enabled?: boolean },
): { width: number; height: number } | null;Tracks an element's size. Several changes within one frame are batched into a single update with
requestAnimationFrame, and an unchanged size doesn't re-render. box is 'content-box'
(default), 'border-box' or 'device-pixel-content-box'. Returns null before the first
measurement, on the server and where ResizeObserver is missing.
'use client';
import { useResizeObserver } from '@akhatmirzo/hooks';
import { Badge } from '@akhatmirzo/ui';
import { useRef } from 'react';
export default function HooksResizeObserver() {
const ref = useRef<HTMLDivElement>(null);
const size = useResizeObserver(ref, { box: 'border-box' });
const compact = size !== null && size.width < 240;
return (
<div
ref={ref}
className="flex h-40 w-72 min-w-40 max-w-full resize flex-col items-center justify-center gap-2 overflow-auto rounded-xl border border-border bg-surface p-4 text-sm text-text"
>
<Badge color={compact ? 'warning' : 'success'} dot>
{compact ? 'Ixcham' : 'Keng'}
</Badge>
<span className="font-mono text-text-muted">
{size ? `${Math.round(size.width)} × ${Math.round(size.height)}` : '…'}
</span>
<span className="text-xs text-text-muted">O'ng pastki burchakdan torting</span>
</div>
);
}
const ref = useRef<HTMLDivElement>(null);
const size = useResizeObserver(ref);
const columns = size && size.width < 480 ? 1 : 3;
return (
<div ref={ref} className="grid" style={{ gridTemplateColumns: `repeat(${columns}, 1fr)` }}>
…
</div>
);useScrollLock
function useScrollLock(
locked: boolean,
options?: { target?: RefObject<HTMLElement | null> | HTMLElement | null },
): void;While locked is true, locks scrolling of the page (document.body by default) or of
target — for modals, drawers and mobile menus. The width of the hidden scrollbar is
compensated with padding-right, so content doesn't jump. Along with overflow: hidden it sets
overscroll-behavior: none for iOS and scroll chaining.
'use client';
import { useScrollLock, useToggle } from '@akhatmirzo/hooks';
import { Badge, Button } from '@akhatmirzo/ui';
export default function HooksScrollLock() {
const [locked, toggle] = useToggle(false);
useScrollLock(locked);
return (
<div className="flex flex-col items-center gap-3">
<Button variant={locked ? 'primary' : 'outline'} onClick={toggle} aria-pressed={locked}>
{locked ? 'Scroll qulfini ochish' : "Sahifa scroll'ini qulflash"}
</Button>
<Badge color={locked ? 'warning' : 'neutral'} dot>
{locked ? "Sahifa qulflangan — g'ildirak bilan aylantirib ko'ring" : 'Sahifa erkin'}
</Badge>
</div>
);
}
const [open, setOpen] = useState(false);
useScrollLock(open);Multiple locks
When several components lock at once (say, a dialog over a drawer), a counter is used: scrolling comes back only when the last lock is released, and the original inline styles are restored.
useFocusTrap
function useFocusTrap(
target: RefObject<HTMLElement | null> | HTMLElement | null,
active: boolean,
options?: {
initialFocus?: RefObject<HTMLElement | null> | HTMLElement | null;
returnFocus?: boolean;
},
): void;While active, focus stays inside the container: Tab wraps from the last element to the first,
Shift+Tab from the first to the last, and focus that escapes is pulled back. On activation it
focuses initialFocus or the first focusable element (or the container itself if there's none).
When deactivated or unmounted, focus returns to the previously focused element (returnFocus,
default true). With nested traps only the most recently activated one is in charge.
'use client';
import { useFocusTrap, useHotkeys, useToggle } from '@akhatmirzo/hooks';
import { Button, Field, Input } from '@akhatmirzo/ui';
import { useRef } from 'react';
export default function HooksFocusTrap() {
const [open, toggle, setOpen] = useToggle(false);
const panelRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useFocusTrap(panelRef, open, { initialFocus: inputRef });
useHotkeys('esc', () => setOpen(false), { enabled: open, enableOnInputs: true });
return (
<div className="flex w-full max-w-sm flex-col items-center gap-3">
<Button variant="outline" onClick={toggle} aria-expanded={open}>
{open ? 'Yopish' : "Manzil qo'shish"}
</Button>
{open && (
<div
ref={panelRef}
className="flex w-full flex-col gap-3 rounded-xl border border-border bg-surface-raised p-4 shadow-lg"
>
<Field label="Shahar">
<Input ref={inputRef} placeholder="Toshkent" />
</Field>
<Field label="Ko'cha">
<Input placeholder="Amir Temur ko'chasi, 15" />
</Field>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setOpen(false)}>
Bekor qilish
</Button>
<Button variant="primary" onClick={() => setOpen(false)}>
Saqlash
</Button>
</div>
</div>
)}
</div>
);
}
const panelRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useFocusTrap(panelRef, open, { initialFocus: inputRef });
useHotkeys('esc', () => setOpen(false), { enabled: open, enableOnInputs: true });Esc and ARIA are up to you
The hook only manages focus. Add a close button, Esc, role="dialog" and aria-modal yourself,
or use the ready-made Dialog / Drawer components.
useLongPress
function useLongPress<T extends Element = Element>(
callback: (event: PointerEvent<T>) => void,
options?: {
delay?: number; // 500
onStart?: (event: PointerEvent<T>) => void;
onCancel?: (event: PointerEvent<T>) => void;
moveThreshold?: number; // 10
preventContextMenu?: boolean; // true
enabled?: boolean; // true
},
): LongPressHandlers<T>;Detects a long press — the same way for mouse, touch and pen (Pointer Events). Returns stable
handlers to spread onto an element. callback fires after delay ms; releasing earlier, leaving
the element or moving more than moveThreshold px (a scroll) cancels it and calls onCancel.
Only the primary mouse button counts. The browser context menu is blocked while pressing
(preventContextMenu: false to allow it).
'use client';
import { useLongPress } from '@akhatmirzo/hooks';
import { Badge } from '@akhatmirzo/ui';
import { useState } from 'react';
export default function HooksLongPress() {
const [status, setStatus] = useState<'idle' | 'pressing' | 'done' | 'cancelled'>('idle');
const handlers = useLongPress<HTMLButtonElement>(() => setStatus('done'), {
delay: 700,
onStart: () => setStatus('pressing'),
onCancel: () => setStatus('cancelled'),
});
const label = {
idle: 'Kutilmoqda',
pressing: 'Bosib turibsiz…',
done: "Buyurtma o'chirishga tanlandi",
cancelled: 'Bekor qilindi',
}[status];
return (
<div className="flex flex-col items-center gap-3">
<button
type="button"
{...handlers}
className="w-64 touch-none select-none rounded-xl border border-border bg-surface px-4 py-3 text-left text-sm text-text transition-colors active:bg-surface-muted"
>
<span className="font-medium">Buyurtma #1024</span>
<span className="block text-text-muted">1 250 000 so'm · 0,7 s bosib turing</span>
</button>
<Badge
color={status === 'done' ? 'success' : status === 'cancelled' ? 'warning' : 'neutral'}
dot
>
{label}
</Badge>
</div>
);
}
const handlers = useLongPress<HTMLLIElement>(() => setSelectMode(true), { delay: 600 });
return <li {...handlers}>Order #1024</li>;On touch screens, add user-select: none (select-none) so text selection doesn't interrupt the
press. Keep the default touch-action on list items: when the page starts scrolling the browser
sends pointercancel and the press is cancelled — exactly what you want in a scrollable list. Only
add touch-action: none (touch-none) to elements that never need to scroll the page.
useDocumentVisibility
function useDocumentVisibility(): 'visible' | 'hidden';Whether the page (tab) is visible. Use it to pause polling, video or animations while the user is
on another tab. Returns 'visible' on the server.
Faol vaqt: 0 s
Boshqa tabga o'tib qayting — hisob to'xtab turadi
'use client';
import { useDocumentVisibility } from '@akhatmirzo/hooks';
import { Badge } from '@akhatmirzo/ui';
import { useEffect, useState } from 'react';
export default function HooksDocumentVisibility() {
const visibility = useDocumentVisibility();
const [seconds, setSeconds] = useState(0);
// Tab yashirin bo'lsa, taymer to'xtaydi — resurs tejaladi
useEffect(() => {
if (visibility === 'hidden') return;
const id = setInterval(() => setSeconds((s) => s + 1), 1000);
return () => clearInterval(id);
}, [visibility]);
return (
<div className="flex flex-col items-center gap-3 text-sm text-text">
<Badge color={visibility === 'visible' ? 'success' : 'neutral'} dot>
{visibility === 'visible' ? "Tab ko'rinib turibdi" : 'Tab yashirin'}
</Badge>
<p>
Faol vaqt: <span className="font-mono">{seconds} s</span>
</p>
<p className="text-text-muted">Boshqa tabga o'tib qayting — hisob to'xtab turadi</p>
</div>
);
}
const visibility = useDocumentVisibility();
useEffect(() => {
if (visibility === 'hidden') return;
const id = setInterval(refreshOrders, 10_000);
return () => clearInterval(id);
}, [visibility]);useIsClient
function useIsClient(): boolean;false on the server and during hydration, true after hydration. Use it to render
browser-only things (charts, window size, "now") without hydration mismatches. Built on
useSyncExternalStore, so later mounts get true right away.
const isClient = useIsClient();
return isClient ? <SalesChart /> : <Skeleton className="h-64" />;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.
'use client';
import { useAsync, useToggle } from '@akhatmirzo/hooks';
import { Badge, Button, Switch } from '@akhatmirzo/ui';
const CITIES = ['Toshkent', 'Samarqand', 'Buxoro', 'Xiva', 'Namangan', 'Andijon', 'Nukus'];
const STATUS_COLOR = {
idle: 'neutral',
loading: 'info',
success: 'success',
error: 'danger',
} as const;
function fetchCities(fail: boolean): Promise<string[]> {
const delay = 500 + Math.random() * 1000;
return new Promise((resolve, reject) =>
setTimeout(() => {
if (fail) reject(new Error('Server javob bermadi'));
else resolve(CITIES.slice(0, 2 + Math.floor(Math.random() * 5)));
}, delay),
);
}
export default function HooksAsync() {
const [fail, , setFail] = useToggle(false);
const { status, data, error, run } = useAsync(fetchCities, [], { immediate: false });
return (
<div className="flex w-full max-w-sm flex-col gap-3">
<div className="flex flex-wrap items-center gap-3">
<Button onClick={() => void run(fail)} loading={status === 'loading'}>
Yuklash
</Button>
<Badge color={STATUS_COLOR[status]} dot>
{status}
</Badge>
</div>
<Switch checked={fail} onCheckedChange={setFail} label="Xato bilan tugasin" />
{status === 'error' && error instanceof Error && (
<p className="text-sm text-danger-soft-foreground">{error.message}</p>
)}
{data && <p className="text-sm text-text">{data.join(', ')}</p>}
</div>
);
}
// 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,
});datais the last successful result. It's kept during reloads and errors, so a list doesn't go blank while loading.runresolves toundefinedfor stale or failed calls and never throws; the error goes toerror.
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
'use client';
import { usePagination } from '@akhatmirzo/hooks';
import { Button } from '@akhatmirzo/ui';
import { useState } from 'react';
export default function HooksPagination() {
const [page, setPage] = useState(5);
const { pages, hasPrev, hasNext, from, to } = usePagination({ total: 200, pageSize: 10, page });
return (
<div className="flex flex-col items-center gap-3">
<nav aria-label="Sahifalar" className="flex flex-wrap items-center justify-center gap-1">
<Button size="sm" variant="ghost" disabled={!hasPrev} onClick={() => setPage(page - 1)}>
Oldingi
</Button>
{pages.map((p, i) =>
p === 'ellipsis' ? (
<span key={`e${i}`} className="px-2 text-text-muted">
…
</span>
) : (
<Button
key={p}
size="sm"
variant={p === page ? 'primary' : 'ghost'}
aria-current={p === page ? 'page' : undefined}
onClick={() => setPage(p)}
>
{p}
</Button>
),
)}
<Button size="sm" variant="ghost" disabled={!hasNext} onClick={() => setPage(page + 1)}>
Keyingi
</Button>
</nav>
<p className="text-sm text-text-muted">
{from}–{to} / 200 ta yozuv
</p>
</div>
);
}
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| Prop | Type | Default | Description |
|---|---|---|---|
totalrequired | number | — | Total number of records |
pageSizerequired | number | — | Records per page |
pagerequired | number | — | Current page, starting at 1. Clamped when out of range |
siblings | number | 1 | Pages 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.
Yuqoridagi tugmalardan birini bosing
'use client';
import { useHotkeys } from '@akhatmirzo/hooks';
import { Badge, Kbd } from '@akhatmirzo/ui';
import { useState } from 'react';
export default function HooksHotkeys() {
const [last, setLast] = useState<string | null>(null);
const [count, setCount] = useState(0);
const hit = (name: string) => {
setLast(name);
setCount((n) => n + 1);
};
useHotkeys('alt+k', () => hit('alt+k'));
useHotkeys('?', () => hit('?'));
// Bir nechta kombinatsiya — vergul bilan
useHotkeys('g, alt+g', () => hit('g'));
return (
<div className="flex flex-col items-center gap-3 text-sm text-text">
<div className="flex flex-wrap items-center justify-center gap-3">
<Kbd keys={['Alt', 'K']} />
<Kbd>?</Kbd>
<Kbd>G</Kbd>
</div>
<p className="text-text-muted">Yuqoridagi tugmalardan birini bosing</p>
{last && (
<Badge color="primary">
{last} · {count} marta
</Badge>
)}
</div>
);
}
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 });| Prop | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | false stops listening (for example while a panel is closed) |
preventDefault | boolean | true | Stops the browser's default action on a match |
enableOnInputs | boolean | false | Also 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 otherKeyboardEvent.keyvalues (tab,f1...). - Symbols: characters like
'?'or'/'work even though they're typed with Shift — no need to writeshift. - On macOS,
altchanges the letter (Alt+K→˚), butalt+kstill 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 serverThe check inside useHotkeys, on its own — for testing a shortcut in your own onKeyDown.
<Input
onKeyDown={(e) => {
if (matchHotkey(e.nativeEvent, 'mod+enter')) submit();
}}
/>Time and flow
useInterval
function useInterval(callback: () => void, delay: number | null): void;Calls callback every delay milliseconds. Pass null to pause the interval; pass a number
again to resume. A new callback on every render does not restart the interval — the latest
function is always called, so it can safely read current state.
'use client';
import { useInterval } from '@akhatmirzo/hooks';
import { Button } from '@akhatmirzo/ui';
import { useState } from 'react';
export default function HooksInterval() {
const [seconds, setSeconds] = useState(0);
const [running, setRunning] = useState(true);
// null — interval to'xtaydi
useInterval(() => setSeconds((s) => s + 1), running ? 1000 : null);
const mm = String(Math.floor(seconds / 60)).padStart(2, '0');
const ss = String(seconds % 60).padStart(2, '0');
return (
<div className="flex flex-col items-center gap-4">
<span className="text-3xl font-semibold tabular-nums text-text">
{mm}:{ss}
</span>
<div className="flex gap-2">
<Button variant="primary" onClick={() => setRunning((r) => !r)}>
{running ? "To'xtatish" : 'Davom ettirish'}
</Button>
<Button variant="ghost" onClick={() => setSeconds(0)}>
Nolga qaytarish
</Button>
</div>
</div>
);
}
const [seconds, setSeconds] = useState(0);
const [running, setRunning] = useState(true);
useInterval(() => setSeconds((s) => s + 1), running ? 1000 : null);useTimeout
function useTimeout(
callback: () => void,
delay: number | null,
): { reset: () => void; clear: () => void };Calls callback once after delay milliseconds. The timer starts on mount and whenever
delay changes, and is cancelled on unmount. clear() cancels the pending call, reset()
starts the timer again from now. delay: null sets no timer.
// The message hides after 4 seconds, but not while the pointer is over it
const { reset, clear } = useTimeout(() => setVisible(false), 4000);
<div onMouseEnter={clear} onMouseLeave={reset}>
Saved
</div>;useCountdown
function useCountdown(
seconds: number,
options?: { autoStart?: boolean; onEnd?: () => void },
): {
remaining: number;
isRunning: boolean;
start: () => void;
pause: () => void;
reset: () => void;
restart: () => void;
formatted: string; // "0:59"
};A countdown for "resend code" buttons, offer deadlines and quiz timers. Time is measured with
Date.now() deltas, so the remaining time stays correct even when the browser throttles timers
in a background tab. formatted is "m:ss" ("2:05", "0:59").
+998 90 *** ** 67 ga yuborildi
'use client';
import { useCountdown } from '@akhatmirzo/hooks';
import { Button, Field, OtpInput, toast } from '@akhatmirzo/ui';
import { useState } from 'react';
export default function HooksCountdown() {
const [code, setCode] = useState('');
const { formatted, isRunning, restart } = useCountdown(59);
const resend = () => {
// Haqiqiy ilovada — SMS yuborish so'rovi
setCode('');
toast('Yangi kod +998 90 *** ** 67 ga yuborildi');
restart();
};
return (
<div className="flex flex-col items-start gap-3">
<Field label="Tasdiqlash kodi" description="+998 90 *** ** 67 ga yuborildi">
<OtpInput groupSize={3} value={code} onValueChange={setCode} />
</Field>
<Button variant="ghost" size="sm" disabled={isRunning} onClick={resend}>
{isRunning ? `Kodni qayta yuborish (${formatted})` : 'Kodni qayta yuborish'}
</Button>
</div>
);
}
const { formatted, isRunning, restart } = useCountdown(59);
<Button
disabled={isRunning}
onClick={() => {
sendSms();
restart();
}}
>
{isRunning ? `Resend code (${formatted})` : 'Resend code'}
</Button>;start()resumes after a pause. If the countdown has finished (remaining: 0), it starts over fromseconds.pause()stops the countdown and keeps the remaining time.reset()goes back tosecondsand stops;restart()goes back and starts right away.onEndfires once per run, when the countdown reaches 0.- Changing
secondsdoes not affect the current run — it applies on the nextreset/restart.
| Prop | Type | Default | Description |
|---|---|---|---|
autoStart | boolean | true | Start on mount |
onEnd | () => void | — | Called when the countdown reaches 0 |
OtpInput.ResendTimer
If you only need a "resend" button, use the ready-made
OtpInput.ResendTimer. Reach for useCountdown when you need your
own UI.
useIdle
function useIdle(ms: number, options?: { events?: string[]; initialState?: boolean }): boolean;Returns true once the user has done nothing for ms milliseconds. Any activity (mouse move,
key press, touch, scroll, returning to the tab) restarts the timer and sets the state back to
false. Use it for auto sign-out or an "Are you still there?" dialog.
5 soniya sichqoncha va klaviaturaga tegmang. Tarmoqni o'chirib ko'ring — holat darhol yangilanadi.
'use client';
import { useIdle, useOnline } from '@akhatmirzo/hooks';
import { Badge } from '@akhatmirzo/ui';
export default function HooksIdle() {
const idle = useIdle(5000);
const online = useOnline();
return (
<div className="flex flex-col items-center gap-3 text-center">
<div className="flex flex-wrap justify-center gap-2">
<Badge color={idle ? 'warning' : 'success'} dot>
{idle ? 'Nofaol' : 'Faol'}
</Badge>
<Badge color={online ? 'success' : 'danger'} dot>
{online ? 'Internet bor' : "Internet yo'q"}
</Badge>
</div>
<p className="max-w-xs text-sm text-text-muted">
5 soniya sichqoncha va klaviaturaga tegmang. Tarmoqni o'chirib ko'ring — holat darhol
yangilanadi.
</p>
</div>
);
}
const idle = useIdle(15 * 60_000);
useEffect(() => {
if (idle) logout();
}, [idle]);| Prop | Type | Default | Description |
|---|---|---|---|
events | string[] | ['mousemove', 'keydown', 'touchstart', 'scroll', 'visibilitychange'] | Activity events. visibilitychange is read on document, the rest on window |
initialState | boolean | false | State on mount |
useStep
function useStep(
count: number,
options?: { initial?: number },
): {
step: number;
next: () => void;
prev: () => void;
goTo: (step: number) => void;
reset: () => void;
isFirst: boolean;
isLast: boolean;
progress: number; // 0..1
};State for multi-step forms (wizards). Steps start at 0 and always stay within 0..count - 1:
next() does nothing on the last step, prev() does nothing on the first, and goTo() clamps.
progress is 0 on the first step and 1 on the last. Pairs with
Stepper.
- 1-qadam, 4 tadan: Savat
- 2-qadam, 4 tadan: Yetkazib berishManzil va vaqt
- 3-qadam, 4 tadan: To'lov
- 4-qadam, 4 tadan: Tasdiqlash
1-qadam, 4 tadan:Savat
'use client';
import { useStep } from '@akhatmirzo/hooks';
import { Button, Progress, Stepper, toast } from '@akhatmirzo/ui';
const steps = [
{ title: 'Savat' },
{ title: 'Yetkazib berish', description: 'Manzil va vaqt' },
{ title: "To'lov" },
{ title: 'Tasdiqlash' },
];
export default function HooksStep() {
const { step, next, prev, goTo, reset, isFirst, isLast, progress } = useStep(steps.length);
return (
<div className="flex w-full flex-col gap-6">
<Stepper steps={steps} value={step} onValueChange={goTo} aria-label="Buyurtma" />
<Progress value={progress * 100} aria-label="Jarayon" />
<div className="flex gap-2">
<Button size="sm" disabled={isFirst} onClick={prev}>
Orqaga
</Button>
<Button
size="sm"
variant="primary"
onClick={() => {
if (!isLast) return next();
toast('Buyurtma qabul qilindi');
reset();
}}
>
{isLast ? 'Buyurtma berish' : 'Davom etish'}
</Button>
</div>
</div>
);
}
const { step, next, prev, goTo, isFirst, isLast } = useStep(steps.length);
<Stepper steps={steps} value={step} onValueChange={goTo} />
<Button disabled={isFirst} onClick={prev}>Back</Button>
<Button onClick={isLast ? submit : next}>{isLast ? 'Place order' : 'Continue'}</Button>useInfiniteScroll
function useInfiniteScroll<T extends Element = HTMLDivElement>(options: {
hasMore: boolean;
loading: boolean;
onLoadMore: () => void;
rootMargin?: string; // '200px'
}): { sentinelRef: (node: T | null) => void };Infinite scroll built on useIntersection. Put a sentinel element at the
end of the list: onLoadMore runs when it comes within rootMargin of the viewport. It is not
called again while loading is true, and observing stops when hasMore is false. If the
sentinel is still visible after a page loads (a short page), the next page loads on its own
until the screen is full.
- Buyurtma #1000
- Buyurtma #1001
- Buyurtma #1002
- Buyurtma #1003
- Buyurtma #1004
- Buyurtma #1005
- Buyurtma #1006
- Buyurtma #1007
- Buyurtma #1008
- Buyurtma #1009
- Buyurtma #1010
- Buyurtma #1011
'use client';
import { useInfiniteScroll } from '@akhatmirzo/hooks';
import { Spinner } from '@akhatmirzo/ui';
import { useState } from 'react';
const TOTAL = 60;
const PAGE = 12;
export default function HooksInfiniteScroll() {
const [items, setItems] = useState(PAGE);
const [loading, setLoading] = useState(false);
const hasMore = items < TOTAL;
const { sentinelRef } = useInfiniteScroll({
hasMore,
loading,
rootMargin: '40px',
onLoadMore: () => {
// Haqiqiy ilovada — keyingi sahifani serverdan yuklash
setLoading(true);
setTimeout(() => {
setItems((n) => Math.min(TOTAL, n + PAGE));
setLoading(false);
}, 600);
},
});
return (
<div className="h-64 w-full max-w-sm overflow-auto rounded-xl border border-border bg-surface">
<ul className="divide-y divide-border text-sm text-text">
{Array.from({ length: items }, (_, i) => (
<li key={i} className="px-4 py-2">
Buyurtma #{1000 + i}
</li>
))}
</ul>
<div
ref={sentinelRef}
className="flex items-center justify-center gap-2 p-3 text-sm text-text-muted"
>
{hasMore ? (
<>
<Spinner size="sm" /> Yuklanmoqda…
</>
) : (
'Hammasi yuklandi'
)}
</div>
</div>
);
}
const { data, loading, hasMore, loadNextPage } = useOrders();
const { sentinelRef } = useInfiniteScroll({ hasMore, loading, onLoadMore: loadNextPage });
return (
<>
{data.map((order) => (
<OrderRow key={order.id} order={order} />
))}
<div ref={sentinelRef}>{hasMore && <Spinner size="sm" />}</div>
</>
);usePrevious
function usePrevious<T>(value: T): T | undefined;The previous value; undefined on the first render. It updates only when the value
changes: re-renders for other reasons keep the previous value (compared with Object.is).
const prevPage = usePrevious(page);
const direction = prevPage !== undefined && page < prevPage ? 'back' : 'forward';useOnline
function useOnline(): boolean;Whether the browser is online — navigator.onLine plus the online / offline events. Returns
true on the server and during hydration, so an "offline" warning never flashes on page load.
See it live in the useIdle demo.
function OfflineBanner() {
const online = useOnline();
if (online) return null;
return (
<Alert banner color="warning">
You are offline. Changes will not be saved.
</Alert>
);
}