@@ -119,6 +119,30 @@ function Component() {
- **[`useScript`](https://hookli.vercel.app/docs/use-script)** — Load an external script and report its load status.
- **[`useFetch`](https://hookli.vercel.app/docs/use-fetch)** — Declarative fetch with loading and error status.
- **[`useGeoLocation`](https://hookli.vercel.app/docs/use-geo-location)** — Browser geolocation state.
+- **[`usePrevious`](https://hookli.vercel.app/docs/use-previous)** — Track a value from the previous render.
+- **[`useList`](https://hookli.vercel.app/docs/use-list)** — Array state with push, insert, update, remove, and clear helpers.
+- **[`useSet`](https://hookli.vercel.app/docs/use-set)** — Set state with add, remove, toggle, has, and clear helpers.
+- **[`useThrottle`](https://hookli.vercel.app/docs/use-throttle)** — Throttle a fast-changing value to at most one update per interval.
+- **[`useUpdateEffect`](https://hookli.vercel.app/docs/use-update-effect)** — A useEffect that skips the initial mount and runs only on updates.
+- **[`useEffectOnce`](https://hookli.vercel.app/docs/use-effect-once)** — Run an effect exactly once, on mount.
+- **[`useKeyPress`](https://hookli.vercel.app/docs/use-key-press)** — Track whether a specific key is currently held down.
+- **[`useWindowScroll`](https://hookli.vercel.app/docs/use-window-scroll)** — Track the window scroll position reactively.
+- **[`useAsync`](https://hookli.vercel.app/docs/use-async)** — Run an async function and track its loading, error, and value state.
+- **[`useMutation`](https://hookli.vercel.app/docs/use-mutation)** — Run an async write action on demand and track status, data, and error.
+- **[`usePagination`](https://hookli.vercel.app/docs/use-pagination)** — Page, page size, total pages, navigation helpers, and the current item range.
+- **[`useNetworkState`](https://hookli.vercel.app/docs/use-network-state)** — Track online/offline status and connection details.
+- **[`usePageVisibility`](https://hookli.vercel.app/docs/use-page-visibility)** — Track whether the page/tab is currently visible.
+- **[`useIdle`](https://hookli.vercel.app/docs/use-idle)** — Detect user inactivity after a configurable threshold.
+- **[`useQueue`](https://hookli.vercel.app/docs/use-queue)** — FIFO queue state with add, remove, clear, and first/last/size.
+- **[`useDefault`](https://hookli.vercel.app/docs/use-default)** — useState that falls back to a default when the value is nullish.
+- **[`useRafState`](https://hookli.vercel.app/docs/use-raf-state)** — useState whose updates are batched to the next animation frame.
+- **[`useDeepCompareEffect`](https://hookli.vercel.app/docs/use-deep-compare-effect)** — useEffect that compares dependencies by deep structural equality.
+- **[`useTextSelection`](https://hookli.vercel.app/docs/use-text-selection)** — Track the text the user has currently selected on the page.
+- **[`useLongPress`](https://hookli.vercel.app/docs/use-long-press)** — Detect a long press (mouse or touch) via spreadable handlers.
+- **[`useHotkeys`](https://hookli.vercel.app/docs/use-hotkeys)** — Bind a keyboard shortcut combo (e.g. ctrl+k) to a callback.
+- **[`useFullscreen`](https://hookli.vercel.app/docs/use-fullscreen)** — Control the Fullscreen API for an element and track its state.
+- **[`useBattery`](https://hookli.vercel.app/docs/use-battery)** — Read device battery level and charging state (where supported).
+- **[`usePermission`](https://hookli.vercel.app/docs/use-permission)** — Query a Permissions API permission and track its state.
## 🧪 TypeScript
diff --git a/apps/docs/public/hookli-banner.png b/apps/docs/public/hookli-banner.png
index d1cb185..711d473 100644
Binary files a/apps/docs/public/hookli-banner.png and b/apps/docs/public/hookli-banner.png differ
diff --git a/apps/docs/public/hookli-banner.svg b/apps/docs/public/hookli-banner.svg
index f634013..51ee9db 100644
--- a/apps/docs/public/hookli-banner.svg
+++ b/apps/docs/public/hookli-banner.svg
@@ -1,54 +1,65 @@
-
\ No newline at end of file
diff --git a/assets/hookli-banner.png b/assets/hookli-banner.png
index d1cb185..711d473 100644
Binary files a/assets/hookli-banner.png and b/assets/hookli-banner.png differ
diff --git a/assets/hookli-banner.svg b/assets/hookli-banner.svg
index f634013..51ee9db 100644
--- a/assets/hookli-banner.svg
+++ b/assets/hookli-banner.svg
@@ -1,54 +1,65 @@
-
-
-
+
-
-
-
-
-
-
+
+
+
+
+
+
-
-
+
+
+ useToggle
+ useForm
+ useLocalStorage
+ useLocalStorageWithExpiry
+ useSessionStorage
+ useReadLocalStorage
+ useDarkMode
+ useTernaryDarkMode
+ useBoolean
+ useCounter
+ useStep
+ useCountdown
+ useMap
+ useDebounce
+ useDebounceValue
+ useDebounceCallback
+ useInterval
+ useTimeout
+ useIsomorphicLayoutEffect
+ useEventCallback
+ useUnmount
+ useIsClient
+ useIsMounted
+ useDocumentTitle
+ useEventListener
+ useClickOutside
+ useMousePosition
+ useInfiniteScroll
+ useExpandableText
+ useHover
+ useIntersectionObserver
+
-
-
-
+
+
+
+
+
+ hookli.
-
-
-
-
-
- hookli.
+ 65 React hooks.
+ One import.
-
-
+ Zero deps · SSR-safe · fully tree-shakable · TypeScript-first
-
- type-safe React hooks
- SSR-safe · tree-shakable · zero dependencies
-
-
-
- 41 HOOKS
-
-
-
-
-
-
-
- import { useLocalStorage } from 'hookli'
-
+
+ npm i hookli
+ hookli.vercel.app
+
\ No newline at end of file
diff --git a/packages/hookli/README.md b/packages/hookli/README.md
index ea52f77..4dce0c2 100644
--- a/packages/hookli/README.md
+++ b/packages/hookli/README.md
@@ -27,7 +27,7 @@
-
+
@@ -117,6 +117,30 @@ function Component() {
- **[`useScript`](https://hookli.vercel.app/docs/use-script)** — Load an external script and report its load status.
- **[`useFetch`](https://hookli.vercel.app/docs/use-fetch)** — Declarative fetch with loading and error status.
- **[`useGeoLocation`](https://hookli.vercel.app/docs/use-geo-location)** — Browser geolocation state.
+- **[`usePrevious`](https://hookli.vercel.app/docs/use-previous)** — Track a value from the previous render.
+- **[`useList`](https://hookli.vercel.app/docs/use-list)** — Array state with push, insert, update, remove, and clear helpers.
+- **[`useSet`](https://hookli.vercel.app/docs/use-set)** — Set state with add, remove, toggle, has, and clear helpers.
+- **[`useThrottle`](https://hookli.vercel.app/docs/use-throttle)** — Throttle a fast-changing value to at most one update per interval.
+- **[`useUpdateEffect`](https://hookli.vercel.app/docs/use-update-effect)** — A useEffect that skips the initial mount and runs only on updates.
+- **[`useEffectOnce`](https://hookli.vercel.app/docs/use-effect-once)** — Run an effect exactly once, on mount.
+- **[`useKeyPress`](https://hookli.vercel.app/docs/use-key-press)** — Track whether a specific key is currently held down.
+- **[`useWindowScroll`](https://hookli.vercel.app/docs/use-window-scroll)** — Track the window scroll position reactively.
+- **[`useAsync`](https://hookli.vercel.app/docs/use-async)** — Run an async function and track its loading, error, and value state.
+- **[`useMutation`](https://hookli.vercel.app/docs/use-mutation)** — Run an async write action on demand and track status, data, and error.
+- **[`usePagination`](https://hookli.vercel.app/docs/use-pagination)** — Page, page size, total pages, navigation helpers, and the current item range.
+- **[`useNetworkState`](https://hookli.vercel.app/docs/use-network-state)** — Track online/offline status and connection details.
+- **[`usePageVisibility`](https://hookli.vercel.app/docs/use-page-visibility)** — Track whether the page/tab is currently visible.
+- **[`useIdle`](https://hookli.vercel.app/docs/use-idle)** — Detect user inactivity after a configurable threshold.
+- **[`useQueue`](https://hookli.vercel.app/docs/use-queue)** — FIFO queue state with add, remove, clear, and first/last/size.
+- **[`useDefault`](https://hookli.vercel.app/docs/use-default)** — useState that falls back to a default when the value is nullish.
+- **[`useRafState`](https://hookli.vercel.app/docs/use-raf-state)** — useState whose updates are batched to the next animation frame.
+- **[`useDeepCompareEffect`](https://hookli.vercel.app/docs/use-deep-compare-effect)** — useEffect that compares dependencies by deep structural equality.
+- **[`useTextSelection`](https://hookli.vercel.app/docs/use-text-selection)** — Track the text the user has currently selected on the page.
+- **[`useLongPress`](https://hookli.vercel.app/docs/use-long-press)** — Detect a long press (mouse or touch) via spreadable handlers.
+- **[`useHotkeys`](https://hookli.vercel.app/docs/use-hotkeys)** — Bind a keyboard shortcut combo (e.g. ctrl+k) to a callback.
+- **[`useFullscreen`](https://hookli.vercel.app/docs/use-fullscreen)** — Control the Fullscreen API for an element and track its state.
+- **[`useBattery`](https://hookli.vercel.app/docs/use-battery)** — Read device battery level and charging state (where supported).
+- **[`usePermission`](https://hookli.vercel.app/docs/use-permission)** — Query a Permissions API permission and track its state.
## 🧪 TypeScript
diff --git a/packages/hookli/ROADMAP.md b/packages/hookli/ROADMAP.md
new file mode 100644
index 0000000..6597852
--- /dev/null
+++ b/packages/hookli/ROADMAP.md
@@ -0,0 +1,69 @@
+# hookli — Roadmap
+
+Tracking doc for growing the hook catalog and the package DX. Work proceeds in **tiers**;
+check items off as they land. Each hook has three tracks:
+
+- **lib** — hook + colocated vitest test + barrel + manifest entry (ships to npm)
+- **docs** — rich doc-page entry in `apps/docs/lib/hook-docs.ts` (usage, API tables, live demo)
+- *(the docs sidebar/index/count auto-derive from the manifest — a hook appears the moment `lib` lands; `docs` just adds the demo + usage)*
+
+> Count: **41 → 65** (Tiers 1–3 lib all shipped ✅). Next open track: per-hook **docs demos**.
+
+---
+
+## Tier 1 — expected-pair gaps (the "why isn't this here?" hooks)
+
+| hook | category | lib | docs | notes |
+|---|---|:--:|:--:|---|
+| `useThrottle` | effects | ✅ | ⬜ | the twin of `useDebounce` |
+| `usePrevious` | state | ✅ | ⬜ | previous render's value |
+| `useUpdateEffect` | effects | ✅ | ⬜ | effect that skips first render |
+| `useEffectOnce` | effects | ✅ | ⬜ | run-once effect |
+| `useList` | state | ✅ | ⬜ | array state (push/remove/clear) — sibling of `useMap` |
+| `useSet` | state | ✅ | ⬜ | Set state — completes map/set/list trio |
+| `useKeyPress` | dom | ✅ | ⬜ | true while a key is held |
+| `useWindowScroll` | dom | ✅ | ⬜ | reactive `{ x, y }` scroll position |
+
+> **Tier 1 lib: ✅ shipped** (41 → 49 hooks, +20 tests, gate green). Docs demos pending.
+
+## Tier 2 — fill the thin `data` category (2 → 8)
+
+| hook | category | lib | docs | notes |
+|---|---|:--:|:--:|---|
+| `useAsync` | data | ✅ | ⬜ | run an async fn → `{ loading, error, value }` |
+| `useMutation` | data | ✅ | ⬜ | async write action with status |
+| `usePagination` | data | ✅ | ⬜ | page/limit/offset state helper |
+| `useNetworkState` | data | ✅ | ⬜ | online/offline + connection info |
+| `usePageVisibility` | effects | ✅ | ⬜ | tab focus/blur via `visibilitychange` |
+| `useIdle` | dom | ✅ | ⬜ | user-inactivity detection |
+
+> **Tier 2 lib: ✅ shipped** (49 → 55; `data` category 2 → 8).
+
+## Tier 3 — rounding-out
+
+| hook | category | lib | docs | notes |
+|---|---|:--:|:--:|---|
+| `useQueue` | state | ✅ | ⬜ | FIFO queue |
+| `useDefault` | state | ✅ | ⬜ | state with nullish fallback |
+| `useRafState` | state | ✅ | ⬜ | state batched to rAF |
+| `useDeepCompareEffect` | effects | ✅ | ⬜ | effect with deep-equal deps |
+| `useTextSelection` | dom | ✅ | ⬜ | current selected text |
+| `useLongPress` | dom | ✅ | ⬜ | long-press handlers |
+| `useHotkeys` | dom | ✅ | ⬜ | keyboard combos (`ctrl+k`) |
+| `useFullscreen` | dom | ✅ | ⬜ | Fullscreen API control |
+| `useBattery` | data | ✅ | ⬜ | battery level/charging |
+| `usePermission` | data | ✅ | ⬜ | Permissions API state |
+
+> **Tier 3 lib: ✅ shipped** (55 → 65). All three tiers' hooks are on npm-ready `main`
+> once merged; per-hook **docs demos** remain the open `docs` track across all tiers.
+
+---
+
+## Package & DX enhancements (not hooks)
+
+- ⬜ `size-limit` in CI + a per-hook gzipped-size badge ("every hook < 0.5 kB")
+- ⬜ Hook generator script (`pnpm new:hook use-foo`) — scaffolds folder + test + manifest entry
+- ⬜ Per-hook subpath exports (`import { useToggle } from "hookli/use-toggle"`)
+- ⬜ Changesets — automated versioning + changelog (also fixes git-tag ↔ npm drift)
+- ⬜ "Compare vs react-use / usehooks-ts" table in the docs
+- ⬜ StackBlitz "open in playground" per hook page
diff --git a/packages/hookli/hooks.manifest.json b/packages/hookli/hooks.manifest.json
index 468ba53..29e018a 100644
--- a/packages/hookli/hooks.manifest.json
+++ b/packages/hookli/hooks.manifest.json
@@ -287,6 +287,174 @@
"description": "Browser geolocation state.",
"category": "data",
"signature": "useGeoLocation(): { location: GeolocationPosition | null; error: GeolocationError | Error | null }"
+ },
+ {
+ "slug": "use-previous",
+ "name": "usePrevious",
+ "description": "Track a value from the previous render.",
+ "category": "state",
+ "signature": "usePrevious(value: T): T | undefined"
+ },
+ {
+ "slug": "use-list",
+ "name": "useList",
+ "description": "Array state with push, insert, update, remove, and clear helpers.",
+ "category": "state",
+ "signature": "useList(initial?: T[]): [T[], UseListActions]"
+ },
+ {
+ "slug": "use-set",
+ "name": "useSet",
+ "description": "Set state with add, remove, toggle, has, and clear helpers.",
+ "category": "state",
+ "signature": "useSet(initial?: Iterable): [Set, UseSetActions]"
+ },
+ {
+ "slug": "use-throttle",
+ "name": "useThrottle",
+ "description": "Throttle a fast-changing value to at most one update per interval.",
+ "category": "effects",
+ "signature": "useThrottle(value: T, interval?: number): T"
+ },
+ {
+ "slug": "use-update-effect",
+ "name": "useUpdateEffect",
+ "description": "A useEffect that skips the initial mount and runs only on updates.",
+ "category": "effects",
+ "signature": "useUpdateEffect(effect: EffectCallback, deps?: DependencyList): void"
+ },
+ {
+ "slug": "use-effect-once",
+ "name": "useEffectOnce",
+ "description": "Run an effect exactly once, on mount.",
+ "category": "effects",
+ "signature": "useEffectOnce(effect: EffectCallback): void"
+ },
+ {
+ "slug": "use-key-press",
+ "name": "useKeyPress",
+ "description": "Track whether a specific key is currently held down.",
+ "category": "dom",
+ "signature": "useKeyPress(targetKey: string): boolean"
+ },
+ {
+ "slug": "use-window-scroll",
+ "name": "useWindowScroll",
+ "description": "Track the window scroll position reactively.",
+ "category": "dom",
+ "signature": "useWindowScroll(): { x: number; y: number }"
+ },
+ {
+ "slug": "use-async",
+ "name": "useAsync",
+ "description": "Run an async function and track its loading, error, and value state.",
+ "category": "data",
+ "signature": "useAsync(asyncFunction: () => Promise, immediate?: boolean): UseAsyncReturn"
+ },
+ {
+ "slug": "use-mutation",
+ "name": "useMutation",
+ "description": "Run an async write action on demand and track status, data, and error.",
+ "category": "data",
+ "signature": "useMutation(mutationFn: (...args: TArgs) => Promise): UseMutationReturn"
+ },
+ {
+ "slug": "use-pagination",
+ "name": "usePagination",
+ "description": "Page, page size, total pages, navigation helpers, and the current item range.",
+ "category": "data",
+ "signature": "usePagination(options?: UsePaginationOptions): UsePaginationReturn"
+ },
+ {
+ "slug": "use-network-state",
+ "name": "useNetworkState",
+ "description": "Track online/offline status and connection details.",
+ "category": "data",
+ "signature": "useNetworkState(): NetworkState"
+ },
+ {
+ "slug": "use-page-visibility",
+ "name": "usePageVisibility",
+ "description": "Track whether the page/tab is currently visible.",
+ "category": "effects",
+ "signature": "usePageVisibility(): boolean"
+ },
+ {
+ "slug": "use-idle",
+ "name": "useIdle",
+ "description": "Detect user inactivity after a configurable threshold.",
+ "category": "dom",
+ "signature": "useIdle(ms?: number): boolean"
+ },
+ {
+ "slug": "use-queue",
+ "name": "useQueue",
+ "description": "FIFO queue state with add, remove, clear, and first/last/size.",
+ "category": "state",
+ "signature": "useQueue(initial?: T[]): UseQueueReturn"
+ },
+ {
+ "slug": "use-default",
+ "name": "useDefault",
+ "description": "useState that falls back to a default when the value is nullish.",
+ "category": "state",
+ "signature": "useDefault(initialValue: T | null | undefined, defaultValue: T): [T, (value: T | null | undefined) => void]"
+ },
+ {
+ "slug": "use-raf-state",
+ "name": "useRafState",
+ "description": "useState whose updates are batched to the next animation frame.",
+ "category": "state",
+ "signature": "useRafState(initialState: T | (() => T)): [T, Dispatch>]"
+ },
+ {
+ "slug": "use-deep-compare-effect",
+ "name": "useDeepCompareEffect",
+ "description": "useEffect that compares dependencies by deep structural equality.",
+ "category": "effects",
+ "signature": "useDeepCompareEffect(effect: EffectCallback, deps: DependencyList): void"
+ },
+ {
+ "slug": "use-text-selection",
+ "name": "useTextSelection",
+ "description": "Track the text the user has currently selected on the page.",
+ "category": "dom",
+ "signature": "useTextSelection(): string"
+ },
+ {
+ "slug": "use-long-press",
+ "name": "useLongPress",
+ "description": "Detect a long press (mouse or touch) via spreadable handlers.",
+ "category": "dom",
+ "signature": "useLongPress(callback: (event: MouseEvent | TouchEvent) => void, options?: UseLongPressOptions): UseLongPressHandlers"
+ },
+ {
+ "slug": "use-hotkeys",
+ "name": "useHotkeys",
+ "description": "Bind a keyboard shortcut combo (e.g. ctrl+k) to a callback.",
+ "category": "dom",
+ "signature": "useHotkeys(keys: string, callback: (event: KeyboardEvent) => void): void"
+ },
+ {
+ "slug": "use-fullscreen",
+ "name": "useFullscreen",
+ "description": "Control the Fullscreen API for an element and track its state.",
+ "category": "dom",
+ "signature": "useFullscreen(ref: RefObject): UseFullscreenReturn"
+ },
+ {
+ "slug": "use-battery",
+ "name": "useBattery",
+ "description": "Read device battery level and charging state (where supported).",
+ "category": "data",
+ "signature": "useBattery(): BatteryState"
+ },
+ {
+ "slug": "use-permission",
+ "name": "usePermission",
+ "description": "Query a Permissions API permission and track its state.",
+ "category": "data",
+ "signature": "usePermission(name: PermissionName): UsePermissionResult"
}
]
}
diff --git a/packages/hookli/package.json b/packages/hookli/package.json
index 168c1e5..6c9df57 100644
--- a/packages/hookli/package.json
+++ b/packages/hookli/package.json
@@ -1,7 +1,7 @@
{
"name": "hookli",
- "version": "1.5.0",
- "description": "hookli — all the React.js custom hooks you frequently use.",
+ "version": "1.6.0",
+ "description": "Every React hook you reach for, in one typed, zero-dependency, tree-shakable package — state, effects, DOM & data. SSR-safe, ESM + CJS.",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
@@ -19,15 +19,26 @@
},
"keywords": [
"hookli",
+ "react",
"react-hooks",
"hooks",
- "react.js",
"custom-hooks",
- "Custom Hooks",
- "node.js",
- "Javascript",
- "Frontend Development",
- "Productivity"
+ "usehooks",
+ "typescript",
+ "typed",
+ "ssr",
+ "nextjs",
+ "tree-shakable",
+ "esm",
+ "zero-dependency",
+ "useDebounce",
+ "useThrottle",
+ "useLocalStorage",
+ "useAsync",
+ "useHotkeys",
+ "usePrevious",
+ "useMediaQuery",
+ "frontend"
],
"author": "Saif Mohamed (https://linkedin.com/in/saifmohamedsv/)",
"license": "ISC",
diff --git a/packages/hookli/src/hooks/index.ts b/packages/hookli/src/hooks/index.ts
index f3da7c8..539c700 100644
--- a/packages/hookli/src/hooks/index.ts
+++ b/packages/hookli/src/hooks/index.ts
@@ -39,3 +39,27 @@ export * from "./use-timeout";
export * from "./use-toggle";
export * from "./use-unmount";
export * from "./use-window-size";
+export * from "./use-previous";
+export * from "./use-throttle";
+export * from "./use-update-effect";
+export * from "./use-effect-once";
+export * from "./use-list";
+export * from "./use-set";
+export * from "./use-key-press";
+export * from "./use-window-scroll";
+export * from "./use-async";
+export * from "./use-mutation";
+export * from "./use-pagination";
+export * from "./use-network-state";
+export * from "./use-page-visibility";
+export * from "./use-idle";
+export * from "./use-queue";
+export * from "./use-default";
+export * from "./use-raf-state";
+export * from "./use-deep-compare-effect";
+export * from "./use-text-selection";
+export * from "./use-long-press";
+export * from "./use-hotkeys";
+export * from "./use-fullscreen";
+export * from "./use-battery";
+export * from "./use-permission";
diff --git a/packages/hookli/src/hooks/use-async/index.ts b/packages/hookli/src/hooks/use-async/index.ts
new file mode 100644
index 0000000..818b0a0
--- /dev/null
+++ b/packages/hookli/src/hooks/use-async/index.ts
@@ -0,0 +1 @@
+export * from "./use-async";
diff --git a/packages/hookli/src/hooks/use-async/use-async.test.ts b/packages/hookli/src/hooks/use-async/use-async.test.ts
new file mode 100644
index 0000000..b4787a8
--- /dev/null
+++ b/packages/hookli/src/hooks/use-async/use-async.test.ts
@@ -0,0 +1,32 @@
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { useAsync } from "./use-async";
+
+describe("useAsync", () => {
+ it("runs on mount and sets the resolved value", async () => {
+ const { result } = renderHook(() => useAsync(() => Promise.resolve(42)));
+ expect(result.current.loading).toBe(true);
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.value).toBe(42);
+ expect(result.current.error).toBeNull();
+ });
+
+ it("captures errors", async () => {
+ const { result } = renderHook(() =>
+ useAsync(() => Promise.reject(new Error("boom"))),
+ );
+ await waitFor(() => expect(result.current.error).toBeInstanceOf(Error));
+ expect(result.current.error?.message).toBe("boom");
+ });
+
+ it("does not run on mount when immediate is false", async () => {
+ const { result } = renderHook(() =>
+ useAsync(() => Promise.resolve(1), false),
+ );
+ expect(result.current.loading).toBe(false);
+ await act(async () => {
+ await result.current.execute();
+ });
+ expect(result.current.value).toBe(1);
+ });
+});
diff --git a/packages/hookli/src/hooks/use-async/use-async.ts b/packages/hookli/src/hooks/use-async/use-async.ts
new file mode 100644
index 0000000..ade574e
--- /dev/null
+++ b/packages/hookli/src/hooks/use-async/use-async.ts
@@ -0,0 +1,65 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+
+/**
+ * The state returned by {@link useAsync}.
+ */
+export interface UseAsyncReturn {
+ /** Whether the async function is currently running. */
+ loading: boolean;
+ /** The error thrown by the last run, or `null`. */
+ error: Error | null;
+ /** The value resolved by the last successful run, or `null`. */
+ value: T | null;
+ /** Run the async function again. */
+ execute: () => Promise;
+}
+
+/**
+ * Runs an async function and tracks its `{ loading, error, value }` state. By
+ * default it runs on mount; pass `immediate = false` to run it only via the
+ * returned `execute`. State is never set after unmount, so it won't leak or warn.
+ *
+ * @param asyncFunction - The async function to run.
+ * @param immediate - Run on mount. Defaults to `true`.
+ * @returns `{ loading, error, value, execute }`.
+ */
+export const useAsync = (
+ asyncFunction: () => Promise,
+ immediate: boolean = true,
+): UseAsyncReturn => {
+ const [loading, setLoading] = useState(immediate);
+ const [error, setError] = useState(null);
+ const [value, setValue] = useState(null);
+
+ const fnRef = useRef(asyncFunction);
+ fnRef.current = asyncFunction;
+ const mounted = useRef(true);
+
+ useEffect(() => {
+ mounted.current = true;
+ return () => {
+ mounted.current = false;
+ };
+ }, []);
+
+ const execute = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const result = await fnRef.current();
+ if (mounted.current) setValue(result);
+ } catch (err) {
+ if (mounted.current) {
+ setError(err instanceof Error ? err : new Error(String(err)));
+ }
+ } finally {
+ if (mounted.current) setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (immediate) void execute();
+ }, [execute, immediate]);
+
+ return { loading, error, value, execute };
+};
diff --git a/packages/hookli/src/hooks/use-battery/index.ts b/packages/hookli/src/hooks/use-battery/index.ts
new file mode 100644
index 0000000..5017407
--- /dev/null
+++ b/packages/hookli/src/hooks/use-battery/index.ts
@@ -0,0 +1 @@
+export * from "./use-battery";
diff --git a/packages/hookli/src/hooks/use-battery/use-battery.test.ts b/packages/hookli/src/hooks/use-battery/use-battery.test.ts
new file mode 100644
index 0000000..c4789f4
--- /dev/null
+++ b/packages/hookli/src/hooks/use-battery/use-battery.test.ts
@@ -0,0 +1,12 @@
+import { renderHook } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { useBattery } from "./use-battery";
+
+describe("useBattery", () => {
+ it("reports unsupported when the Battery API is missing", () => {
+ const { result } = renderHook(() => useBattery());
+ expect(result.current.supported).toBe(false);
+ expect(result.current.loading).toBe(false);
+ expect(result.current.level).toBeNull();
+ });
+});
diff --git a/packages/hookli/src/hooks/use-battery/use-battery.ts b/packages/hookli/src/hooks/use-battery/use-battery.ts
new file mode 100644
index 0000000..75091bd
--- /dev/null
+++ b/packages/hookli/src/hooks/use-battery/use-battery.ts
@@ -0,0 +1,95 @@
+import { useEffect, useState } from "react";
+
+/** The battery snapshot returned by {@link useBattery}. */
+export interface BatteryState {
+ /** Whether the Battery Status API is available. */
+ supported: boolean;
+ /** Whether the first reading is still pending. */
+ loading: boolean;
+ /** Charge level from `0` to `1`, or `null`. */
+ level: number | null;
+ /** Whether the device is charging, or `null`. */
+ charging: boolean | null;
+ /** Seconds until fully charged, or `null`. */
+ chargingTime: number | null;
+ /** Seconds until empty, or `null`. */
+ dischargingTime: number | null;
+}
+
+interface BatteryManagerLike {
+ level: number;
+ charging: boolean;
+ chargingTime: number;
+ dischargingTime: number;
+ addEventListener: (type: string, listener: () => void) => void;
+ removeEventListener: (type: string, listener: () => void) => void;
+}
+
+const EVENTS = [
+ "levelchange",
+ "chargingchange",
+ "chargingtimechange",
+ "dischargingtimechange",
+];
+
+/**
+ * Reads the device battery via the Battery Status API and keeps it updated.
+ * SSR-safe and degrades gracefully: where the API is missing, `supported` is
+ * `false` and the numeric fields stay `null`.
+ *
+ * @returns The current {@link BatteryState}.
+ */
+export const useBattery = (): BatteryState => {
+ const [state, setState] = useState({
+ supported:
+ typeof navigator !== "undefined" && "getBattery" in navigator,
+ loading: true,
+ level: null,
+ charging: null,
+ chargingTime: null,
+ dischargingTime: null,
+ });
+
+ useEffect(() => {
+ const getBattery = (
+ navigator as unknown as {
+ getBattery?: () => Promise;
+ }
+ ).getBattery;
+
+ if (!getBattery) {
+ setState((prev) => ({ ...prev, supported: false, loading: false }));
+ return;
+ }
+
+ let battery: BatteryManagerLike | null = null;
+ let cancelled = false;
+ const update = () => {
+ if (!battery) return;
+ setState({
+ supported: true,
+ loading: false,
+ level: battery.level,
+ charging: battery.charging,
+ chargingTime: battery.chargingTime,
+ dischargingTime: battery.dischargingTime,
+ });
+ };
+
+ void getBattery.call(navigator).then((result) => {
+ if (cancelled) return;
+ battery = result;
+ update();
+ for (const event of EVENTS) battery.addEventListener(event, update);
+ });
+
+ return () => {
+ cancelled = true;
+ if (battery) {
+ for (const event of EVENTS) battery.removeEventListener(event, update);
+ }
+ };
+ }, []);
+
+ return state;
+};
diff --git a/packages/hookli/src/hooks/use-deep-compare-effect/index.ts b/packages/hookli/src/hooks/use-deep-compare-effect/index.ts
new file mode 100644
index 0000000..ba40e61
--- /dev/null
+++ b/packages/hookli/src/hooks/use-deep-compare-effect/index.ts
@@ -0,0 +1 @@
+export * from "./use-deep-compare-effect";
diff --git a/packages/hookli/src/hooks/use-deep-compare-effect/use-deep-compare-effect.test.ts b/packages/hookli/src/hooks/use-deep-compare-effect/use-deep-compare-effect.test.ts
new file mode 100644
index 0000000..610a069
--- /dev/null
+++ b/packages/hookli/src/hooks/use-deep-compare-effect/use-deep-compare-effect.test.ts
@@ -0,0 +1,29 @@
+import { renderHook } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { useDeepCompareEffect } from "./use-deep-compare-effect";
+
+describe("useDeepCompareEffect", () => {
+ it("runs on mount", () => {
+ const effect = vi.fn();
+ renderHook(() => useDeepCompareEffect(effect, [{ a: 1 }]));
+ expect(effect).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not re-run for deeply-equal deps", () => {
+ const effect = vi.fn();
+ const { rerender } = renderHook(({ deps }) => useDeepCompareEffect(effect, deps), {
+ initialProps: { deps: [{ a: 1 }] as unknown[] },
+ });
+ rerender({ deps: [{ a: 1 }] }); // new reference, same shape
+ expect(effect).toHaveBeenCalledTimes(1);
+ });
+
+ it("re-runs when deps deeply change", () => {
+ const effect = vi.fn();
+ const { rerender } = renderHook(({ deps }) => useDeepCompareEffect(effect, deps), {
+ initialProps: { deps: [{ a: 1 }] as unknown[] },
+ });
+ rerender({ deps: [{ a: 2 }] });
+ expect(effect).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/packages/hookli/src/hooks/use-deep-compare-effect/use-deep-compare-effect.ts b/packages/hookli/src/hooks/use-deep-compare-effect/use-deep-compare-effect.ts
new file mode 100644
index 0000000..859eaad
--- /dev/null
+++ b/packages/hookli/src/hooks/use-deep-compare-effect/use-deep-compare-effect.ts
@@ -0,0 +1,44 @@
+import { useEffect, useRef, type DependencyList, type EffectCallback } from "react";
+
+/** Minimal structural deep-equality for dependency lists (no external deps). */
+const deepEqual = (a: unknown, b: unknown): boolean => {
+ if (Object.is(a, b)) return true;
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) {
+ return false;
+ }
+ const aKeys = Object.keys(a as object);
+ const bKeys = Object.keys(b as object);
+ if (aKeys.length !== bKeys.length) return false;
+ return aKeys.every(
+ (key) =>
+ Object.prototype.hasOwnProperty.call(b, key) &&
+ deepEqual(
+ (a as Record)[key],
+ (b as Record)[key],
+ ),
+ );
+};
+
+/**
+ * A `useEffect` that compares its dependencies by **deep** structural equality
+ * instead of reference. Use it when deps are objects/arrays recreated on every
+ * render (so a normal `useEffect` would fire every time).
+ *
+ * @param effect - Imperative effect callback; may return a cleanup function.
+ * @param deps - Dependency list, compared deeply.
+ */
+export const useDeepCompareEffect = (
+ effect: EffectCallback,
+ deps: DependencyList,
+): void => {
+ const ref = useRef(undefined);
+ const signal = useRef(0);
+
+ if (ref.current === undefined || !deepEqual(deps, ref.current)) {
+ ref.current = deps;
+ signal.current += 1;
+ }
+
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ useEffect(effect, [signal.current]);
+};
diff --git a/packages/hookli/src/hooks/use-default/index.ts b/packages/hookli/src/hooks/use-default/index.ts
new file mode 100644
index 0000000..b3288f8
--- /dev/null
+++ b/packages/hookli/src/hooks/use-default/index.ts
@@ -0,0 +1 @@
+export * from "./use-default";
diff --git a/packages/hookli/src/hooks/use-default/use-default.test.ts b/packages/hookli/src/hooks/use-default/use-default.test.ts
new file mode 100644
index 0000000..c109a51
--- /dev/null
+++ b/packages/hookli/src/hooks/use-default/use-default.test.ts
@@ -0,0 +1,21 @@
+import { act, renderHook } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { useDefault } from "./use-default";
+
+describe("useDefault", () => {
+ it("returns the value when set", () => {
+ const { result } = renderHook(() => useDefault("a", "fallback"));
+ expect(result.current[0]).toBe("a");
+ });
+
+ it("falls back to the default when nullish", () => {
+ const { result } = renderHook(() => useDefault(null, "fallback"));
+ expect(result.current[0]).toBe("fallback");
+
+ act(() => result.current[1]("b"));
+ expect(result.current[0]).toBe("b");
+
+ act(() => result.current[1](undefined));
+ expect(result.current[0]).toBe("fallback");
+ });
+});
diff --git a/packages/hookli/src/hooks/use-default/use-default.ts b/packages/hookli/src/hooks/use-default/use-default.ts
new file mode 100644
index 0000000..fd58c43
--- /dev/null
+++ b/packages/hookli/src/hooks/use-default/use-default.ts
@@ -0,0 +1,18 @@
+import { useState } from "react";
+
+/**
+ * Like `useState`, but reads back the `defaultValue` whenever the state is
+ * `null` or `undefined`. The setter still accepts `null`/`undefined` — the
+ * fallback is applied only on read.
+ *
+ * @param initialValue - The initial value.
+ * @param defaultValue - The value returned whenever the state is nullish.
+ * @returns A `[value, setValue]` tuple.
+ */
+export const useDefault = (
+ initialValue: T | null | undefined,
+ defaultValue: T,
+): [T, (value: T | null | undefined) => void] => {
+ const [value, setValue] = useState(initialValue);
+ return [value === null || value === undefined ? defaultValue : value, setValue];
+};
diff --git a/packages/hookli/src/hooks/use-effect-once/index.ts b/packages/hookli/src/hooks/use-effect-once/index.ts
new file mode 100644
index 0000000..56b3746
--- /dev/null
+++ b/packages/hookli/src/hooks/use-effect-once/index.ts
@@ -0,0 +1 @@
+export * from "./use-effect-once";
diff --git a/packages/hookli/src/hooks/use-effect-once/use-effect-once.test.ts b/packages/hookli/src/hooks/use-effect-once/use-effect-once.test.ts
new file mode 100644
index 0000000..684a29f
--- /dev/null
+++ b/packages/hookli/src/hooks/use-effect-once/use-effect-once.test.ts
@@ -0,0 +1,21 @@
+import { renderHook } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { useEffectOnce } from "./use-effect-once";
+
+describe("useEffectOnce", () => {
+ it("runs once on mount and not on re-render", () => {
+ const effect = vi.fn();
+ const { rerender } = renderHook(() => useEffectOnce(effect));
+ rerender();
+ rerender();
+ expect(effect).toHaveBeenCalledTimes(1);
+ });
+
+ it("runs the cleanup on unmount", () => {
+ const cleanup = vi.fn();
+ const { unmount } = renderHook(() => useEffectOnce(() => cleanup));
+ expect(cleanup).not.toHaveBeenCalled();
+ unmount();
+ expect(cleanup).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/hookli/src/hooks/use-effect-once/use-effect-once.ts b/packages/hookli/src/hooks/use-effect-once/use-effect-once.ts
new file mode 100644
index 0000000..7867f6d
--- /dev/null
+++ b/packages/hookli/src/hooks/use-effect-once/use-effect-once.ts
@@ -0,0 +1,14 @@
+import { useEffect, type EffectCallback } from "react";
+
+/**
+ * Runs an effect exactly once, on mount. A convenience wrapper around
+ * `useEffect(effect, [])` that makes the intent explicit and contains the
+ * exhaustive-deps exception in one place.
+ *
+ * @param effect - Imperative effect callback; may return a cleanup function that
+ * runs on unmount.
+ */
+export const useEffectOnce = (effect: EffectCallback): void => {
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ useEffect(effect, []);
+};
diff --git a/packages/hookli/src/hooks/use-fullscreen/index.ts b/packages/hookli/src/hooks/use-fullscreen/index.ts
new file mode 100644
index 0000000..4b2406b
--- /dev/null
+++ b/packages/hookli/src/hooks/use-fullscreen/index.ts
@@ -0,0 +1 @@
+export * from "./use-fullscreen";
diff --git a/packages/hookli/src/hooks/use-fullscreen/use-fullscreen.test.ts b/packages/hookli/src/hooks/use-fullscreen/use-fullscreen.test.ts
new file mode 100644
index 0000000..9d965f1
--- /dev/null
+++ b/packages/hookli/src/hooks/use-fullscreen/use-fullscreen.test.ts
@@ -0,0 +1,17 @@
+import { act, renderHook } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { useFullscreen } from "./use-fullscreen";
+
+describe("useFullscreen", () => {
+ it("defaults to not fullscreen and exposes controls", async () => {
+ const ref = { current: document.createElement("div") };
+ const { result } = renderHook(() => useFullscreen(ref));
+ expect(result.current.isFullscreen).toBe(false);
+ expect(typeof result.current.enter).toBe("function");
+ expect(typeof result.current.toggle).toBe("function");
+ // no-op where the Fullscreen API is unavailable (jsdom) — should not throw
+ await act(async () => {
+ await result.current.enter();
+ });
+ });
+});
diff --git a/packages/hookli/src/hooks/use-fullscreen/use-fullscreen.ts b/packages/hookli/src/hooks/use-fullscreen/use-fullscreen.ts
new file mode 100644
index 0000000..16b1daa
--- /dev/null
+++ b/packages/hookli/src/hooks/use-fullscreen/use-fullscreen.ts
@@ -0,0 +1,51 @@
+import { useCallback, useEffect, useState, type RefObject } from "react";
+
+/** The value returned by {@link useFullscreen}. */
+export interface UseFullscreenReturn {
+ /** Whether the referenced element is currently fullscreen. */
+ isFullscreen: boolean;
+ /** Request fullscreen for the referenced element. */
+ enter: () => Promise;
+ /** Exit fullscreen (if this element is fullscreen). */
+ exit: () => Promise;
+ /** Toggle fullscreen for the referenced element. */
+ toggle: () => Promise;
+}
+
+/**
+ * Controls the Fullscreen API for a referenced element and tracks whether it is
+ * currently fullscreen (via `fullscreenchange`). Listeners attach in an effect,
+ * so the hook is SSR-safe; calls are no-ops where the API is unavailable.
+ *
+ * @param ref - A ref to the element to make fullscreen.
+ * @returns `{ isFullscreen, enter, exit, toggle }`.
+ */
+export const useFullscreen = (
+ ref: RefObject,
+): UseFullscreenReturn => {
+ const [isFullscreen, setIsFullscreen] = useState(false);
+
+ useEffect(() => {
+ const onChange = () =>
+ setIsFullscreen(document.fullscreenElement === ref.current);
+ document.addEventListener("fullscreenchange", onChange);
+ return () => document.removeEventListener("fullscreenchange", onChange);
+ }, [ref]);
+
+ const enter = useCallback(async () => {
+ await ref.current?.requestFullscreen?.();
+ }, [ref]);
+
+ const exit = useCallback(async () => {
+ if (typeof document !== "undefined" && document.fullscreenElement) {
+ await document.exitFullscreen?.();
+ }
+ }, []);
+
+ const toggle = useCallback(async () => {
+ if (document.fullscreenElement === ref.current) await exit();
+ else await enter();
+ }, [enter, exit, ref]);
+
+ return { isFullscreen, enter, exit, toggle };
+};
diff --git a/packages/hookli/src/hooks/use-hotkeys/index.ts b/packages/hookli/src/hooks/use-hotkeys/index.ts
new file mode 100644
index 0000000..7994a23
--- /dev/null
+++ b/packages/hookli/src/hooks/use-hotkeys/index.ts
@@ -0,0 +1 @@
+export * from "./use-hotkeys";
diff --git a/packages/hookli/src/hooks/use-hotkeys/use-hotkeys.test.ts b/packages/hookli/src/hooks/use-hotkeys/use-hotkeys.test.ts
new file mode 100644
index 0000000..084ef7a
--- /dev/null
+++ b/packages/hookli/src/hooks/use-hotkeys/use-hotkeys.test.ts
@@ -0,0 +1,32 @@
+import { act, renderHook } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { useHotkeys } from "./use-hotkeys";
+
+describe("useHotkeys", () => {
+ it("fires on the matching combo", () => {
+ const cb = vi.fn();
+ renderHook(() => useHotkeys("ctrl+k", cb));
+ act(() => {
+ window.dispatchEvent(new KeyboardEvent("keydown", { key: "k", ctrlKey: true }));
+ });
+ expect(cb).toHaveBeenCalledTimes(1);
+ });
+
+ it("ignores the key without its modifier", () => {
+ const cb = vi.fn();
+ renderHook(() => useHotkeys("ctrl+k", cb));
+ act(() => {
+ window.dispatchEvent(new KeyboardEvent("keydown", { key: "k" }));
+ });
+ expect(cb).not.toHaveBeenCalled();
+ });
+
+ it("matches a single key", () => {
+ const cb = vi.fn();
+ renderHook(() => useHotkeys("Escape", cb));
+ act(() => {
+ window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
+ });
+ expect(cb).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/hookli/src/hooks/use-hotkeys/use-hotkeys.ts b/packages/hookli/src/hooks/use-hotkeys/use-hotkeys.ts
new file mode 100644
index 0000000..ca33c39
--- /dev/null
+++ b/packages/hookli/src/hooks/use-hotkeys/use-hotkeys.ts
@@ -0,0 +1,58 @@
+import { useEffect, useRef } from "react";
+
+const MODIFIERS = [
+ "ctrl",
+ "control",
+ "meta",
+ "cmd",
+ "command",
+ "shift",
+ "alt",
+ "option",
+];
+
+/**
+ * Binds a keyboard shortcut to a callback. Accepts a `"+"`-separated combo such
+ * as `"ctrl+k"`, `"meta+shift+p"`, or a single key like `"Escape"`. The listener
+ * attaches to `window` in an effect (SSR-safe) and calls `preventDefault()` on a
+ * match. The callback is held in a ref, so re-renders don't re-bind.
+ *
+ * @param keys - The combo, e.g. `"ctrl+k"` (case-insensitive).
+ * @param callback - Called with the `KeyboardEvent` when the combo matches.
+ */
+export const useHotkeys = (
+ keys: string,
+ callback: (event: KeyboardEvent) => void,
+): void => {
+ const callbackRef = useRef(callback);
+ callbackRef.current = callback;
+
+ useEffect(() => {
+ const parts = keys.toLowerCase().split("+").map((part) => part.trim());
+ const needCtrl = parts.includes("ctrl") || parts.includes("control");
+ const needMeta =
+ parts.includes("meta") ||
+ parts.includes("cmd") ||
+ parts.includes("command");
+ const needShift = parts.includes("shift");
+ const needAlt = parts.includes("alt") || parts.includes("option");
+ const mainKey = parts.find((part) => !MODIFIERS.includes(part));
+
+ const handler = (event: KeyboardEvent) => {
+ if (!mainKey) return;
+ if (
+ event.ctrlKey === needCtrl &&
+ event.metaKey === needMeta &&
+ event.shiftKey === needShift &&
+ event.altKey === needAlt &&
+ event.key.toLowerCase() === mainKey
+ ) {
+ event.preventDefault();
+ callbackRef.current(event);
+ }
+ };
+
+ window.addEventListener("keydown", handler);
+ return () => window.removeEventListener("keydown", handler);
+ }, [keys]);
+};
diff --git a/packages/hookli/src/hooks/use-idle/index.ts b/packages/hookli/src/hooks/use-idle/index.ts
new file mode 100644
index 0000000..433b1aa
--- /dev/null
+++ b/packages/hookli/src/hooks/use-idle/index.ts
@@ -0,0 +1 @@
+export * from "./use-idle";
diff --git a/packages/hookli/src/hooks/use-idle/use-idle.test.ts b/packages/hookli/src/hooks/use-idle/use-idle.test.ts
new file mode 100644
index 0000000..d8670d4
--- /dev/null
+++ b/packages/hookli/src/hooks/use-idle/use-idle.test.ts
@@ -0,0 +1,31 @@
+import { act, renderHook } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { useIdle } from "./use-idle";
+
+describe("useIdle", () => {
+ afterEach(() => vi.useRealTimers());
+
+ it("becomes idle after the threshold", () => {
+ vi.useFakeTimers();
+ const { result } = renderHook(() => useIdle(1000));
+ expect(result.current).toBe(false);
+ act(() => {
+ vi.advanceTimersByTime(1000);
+ });
+ expect(result.current).toBe(true);
+ });
+
+ it("resets on activity", () => {
+ vi.useFakeTimers();
+ const { result } = renderHook(() => useIdle(1000));
+ act(() => {
+ vi.advanceTimersByTime(1000);
+ });
+ expect(result.current).toBe(true);
+
+ act(() => {
+ window.dispatchEvent(new Event("mousemove"));
+ });
+ expect(result.current).toBe(false);
+ });
+});
diff --git a/packages/hookli/src/hooks/use-idle/use-idle.ts b/packages/hookli/src/hooks/use-idle/use-idle.ts
new file mode 100644
index 0000000..bbcdbbe
--- /dev/null
+++ b/packages/hookli/src/hooks/use-idle/use-idle.ts
@@ -0,0 +1,45 @@
+import { useEffect, useState } from "react";
+
+const ACTIVITY_EVENTS = [
+ "mousemove",
+ "mousedown",
+ "keydown",
+ "touchstart",
+ "scroll",
+ "wheel",
+] as const;
+
+/**
+ * Reports whether the user has been idle (no activity) for at least `ms`
+ * milliseconds. Any mouse/keyboard/touch/scroll activity resets the timer.
+ * Listeners attach in an effect, so it is SSR-safe.
+ *
+ * @param ms - Inactivity threshold in milliseconds. Defaults to `60000` (1 min).
+ * @returns `true` once the user has been idle for `ms`, `false` while active.
+ */
+export const useIdle = (ms: number = 60_000): boolean => {
+ const [idle, setIdle] = useState(false);
+
+ useEffect(() => {
+ let timeoutId: ReturnType;
+
+ const reset = () => {
+ setIdle(false);
+ clearTimeout(timeoutId);
+ timeoutId = setTimeout(() => setIdle(true), ms);
+ };
+
+ reset();
+ for (const event of ACTIVITY_EVENTS) {
+ window.addEventListener(event, reset, { passive: true });
+ }
+ return () => {
+ clearTimeout(timeoutId);
+ for (const event of ACTIVITY_EVENTS) {
+ window.removeEventListener(event, reset);
+ }
+ };
+ }, [ms]);
+
+ return idle;
+};
diff --git a/packages/hookli/src/hooks/use-key-press/index.ts b/packages/hookli/src/hooks/use-key-press/index.ts
new file mode 100644
index 0000000..c595813
--- /dev/null
+++ b/packages/hookli/src/hooks/use-key-press/index.ts
@@ -0,0 +1 @@
+export * from "./use-key-press";
diff --git a/packages/hookli/src/hooks/use-key-press/use-key-press.test.ts b/packages/hookli/src/hooks/use-key-press/use-key-press.test.ts
new file mode 100644
index 0000000..e7e6a49
--- /dev/null
+++ b/packages/hookli/src/hooks/use-key-press/use-key-press.test.ts
@@ -0,0 +1,32 @@
+import { act, renderHook } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { useKeyPress } from "./use-key-press";
+
+describe("useKeyPress", () => {
+ it("is false initially", () => {
+ const { result } = renderHook(() => useKeyPress("a"));
+ expect(result.current).toBe(false);
+ });
+
+ it("tracks keydown / keyup for the target key", () => {
+ const { result } = renderHook(() => useKeyPress("a"));
+
+ act(() => {
+ window.dispatchEvent(new KeyboardEvent("keydown", { key: "a" }));
+ });
+ expect(result.current).toBe(true);
+
+ act(() => {
+ window.dispatchEvent(new KeyboardEvent("keyup", { key: "a" }));
+ });
+ expect(result.current).toBe(false);
+ });
+
+ it("ignores other keys", () => {
+ const { result } = renderHook(() => useKeyPress("a"));
+ act(() => {
+ window.dispatchEvent(new KeyboardEvent("keydown", { key: "b" }));
+ });
+ expect(result.current).toBe(false);
+ });
+});
diff --git a/packages/hookli/src/hooks/use-key-press/use-key-press.ts b/packages/hookli/src/hooks/use-key-press/use-key-press.ts
new file mode 100644
index 0000000..6eb2b29
--- /dev/null
+++ b/packages/hookli/src/hooks/use-key-press/use-key-press.ts
@@ -0,0 +1,30 @@
+import { useEffect, useState } from "react";
+
+/**
+ * Tracks whether a specific key is currently held down. Listens for `keydown`
+ * and `keyup` on `window` (attached in an effect, so SSR-safe) and removes the
+ * listeners on unmount.
+ *
+ * @param targetKey - The `KeyboardEvent.key` to watch (e.g. `"Enter"`, `"a"`, `"Escape"`).
+ * @returns `true` while the key is pressed, `false` otherwise.
+ */
+export const useKeyPress = (targetKey: string): boolean => {
+ const [pressed, setPressed] = useState(false);
+
+ useEffect(() => {
+ const down = (event: KeyboardEvent) => {
+ if (event.key === targetKey) setPressed(true);
+ };
+ const up = (event: KeyboardEvent) => {
+ if (event.key === targetKey) setPressed(false);
+ };
+ window.addEventListener("keydown", down);
+ window.addEventListener("keyup", up);
+ return () => {
+ window.removeEventListener("keydown", down);
+ window.removeEventListener("keyup", up);
+ };
+ }, [targetKey]);
+
+ return pressed;
+};
diff --git a/packages/hookli/src/hooks/use-list/index.ts b/packages/hookli/src/hooks/use-list/index.ts
new file mode 100644
index 0000000..2b009c7
--- /dev/null
+++ b/packages/hookli/src/hooks/use-list/index.ts
@@ -0,0 +1 @@
+export * from "./use-list";
diff --git a/packages/hookli/src/hooks/use-list/use-list.test.ts b/packages/hookli/src/hooks/use-list/use-list.test.ts
new file mode 100644
index 0000000..70a25d3
--- /dev/null
+++ b/packages/hookli/src/hooks/use-list/use-list.test.ts
@@ -0,0 +1,34 @@
+import { act, renderHook } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { useList } from "./use-list";
+
+describe("useList", () => {
+ it("defaults to an empty list", () => {
+ const { result } = renderHook(() => useList());
+ expect(result.current[0]).toEqual([]);
+ });
+
+ it("push / insertAt / updateAt / removeAt work immutably", () => {
+ const { result } = renderHook(() => useList([1, 2, 3]));
+
+ act(() => result.current[1].push(4));
+ expect(result.current[0]).toEqual([1, 2, 3, 4]);
+
+ act(() => result.current[1].insertAt(1, 9));
+ expect(result.current[0]).toEqual([1, 9, 2, 3, 4]);
+
+ act(() => result.current[1].updateAt(0, 0));
+ expect(result.current[0]).toEqual([0, 9, 2, 3, 4]);
+
+ act(() => result.current[1].removeAt(1));
+ expect(result.current[0]).toEqual([0, 2, 3, 4]);
+ });
+
+ it("clear and reset", () => {
+ const { result } = renderHook(() => useList([1, 2]));
+ act(() => result.current[1].clear());
+ expect(result.current[0]).toEqual([]);
+ act(() => result.current[1].reset());
+ expect(result.current[0]).toEqual([1, 2]);
+ });
+});
diff --git a/packages/hookli/src/hooks/use-list/use-list.ts b/packages/hookli/src/hooks/use-list/use-list.ts
new file mode 100644
index 0000000..423f928
--- /dev/null
+++ b/packages/hookli/src/hooks/use-list/use-list.ts
@@ -0,0 +1,57 @@
+import { useMemo, useState } from "react";
+
+/**
+ * Actions returned by {@link useList} for manipulating the array immutably.
+ */
+export interface UseListActions {
+ /** Replace the whole list. */
+ set: (list: T[]) => void;
+ /** Append one or more items to the end. */
+ push: (...items: T[]) => void;
+ /** Insert an item at `index`, shifting the rest right. */
+ insertAt: (index: number, item: T) => void;
+ /** Replace the item at `index`. */
+ updateAt: (index: number, item: T) => void;
+ /** Remove the item at `index`. */
+ removeAt: (index: number) => void;
+ /** Empty the list. */
+ clear: () => void;
+ /** Reset to the initial list the hook was created with. */
+ reset: () => void;
+}
+
+/**
+ * Manages array state with a set of immutable helpers (`push`, `insertAt`,
+ * `updateAt`, `removeAt`, `clear`, `reset`). Every helper produces a new array,
+ * so referential-equality checks and memoization work as expected.
+ *
+ * @param initial - The initial array. Defaults to `[]`.
+ * @returns A tuple of `[list, actions]`.
+ */
+export const useList = (initial: T[] = []): [T[], UseListActions] => {
+ const [list, setList] = useState(initial);
+
+ const actions = useMemo>(
+ () => ({
+ set: (next) => setList(next),
+ push: (...items) => setList((prev) => [...prev, ...items]),
+ insertAt: (index, item) =>
+ setList((prev) => {
+ const next = prev.slice();
+ next.splice(index, 0, item);
+ return next;
+ }),
+ updateAt: (index, item) =>
+ setList((prev) => prev.map((value, i) => (i === index ? item : value))),
+ removeAt: (index) =>
+ setList((prev) => prev.filter((_, i) => i !== index)),
+ clear: () => setList([]),
+ reset: () => setList(initial),
+ }),
+ // `initial` is intentionally captured once (reset restores the original list).
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [],
+ );
+
+ return [list, actions];
+};
diff --git a/packages/hookli/src/hooks/use-long-press/index.ts b/packages/hookli/src/hooks/use-long-press/index.ts
new file mode 100644
index 0000000..a4a9857
--- /dev/null
+++ b/packages/hookli/src/hooks/use-long-press/index.ts
@@ -0,0 +1 @@
+export * from "./use-long-press";
diff --git a/packages/hookli/src/hooks/use-long-press/use-long-press.test.ts b/packages/hookli/src/hooks/use-long-press/use-long-press.test.ts
new file mode 100644
index 0000000..4c390df
--- /dev/null
+++ b/packages/hookli/src/hooks/use-long-press/use-long-press.test.ts
@@ -0,0 +1,29 @@
+import { act, renderHook } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { useLongPress } from "./use-long-press";
+
+describe("useLongPress", () => {
+ afterEach(() => vi.useRealTimers());
+
+ it("fires after the delay", () => {
+ vi.useFakeTimers();
+ const cb = vi.fn();
+ const { result } = renderHook(() => useLongPress(cb, { delay: 500 }));
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ act(() => result.current.onMouseDown({} as any));
+ expect(cb).not.toHaveBeenCalled();
+ act(() => vi.advanceTimersByTime(500));
+ expect(cb).toHaveBeenCalledTimes(1);
+ });
+
+ it("cancels when released early", () => {
+ vi.useFakeTimers();
+ const cb = vi.fn();
+ const { result } = renderHook(() => useLongPress(cb, { delay: 500 }));
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ act(() => result.current.onMouseDown({} as any));
+ act(() => result.current.onMouseUp());
+ act(() => vi.advanceTimersByTime(500));
+ expect(cb).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/hookli/src/hooks/use-long-press/use-long-press.ts b/packages/hookli/src/hooks/use-long-press/use-long-press.ts
new file mode 100644
index 0000000..7b3143b
--- /dev/null
+++ b/packages/hookli/src/hooks/use-long-press/use-long-press.ts
@@ -0,0 +1,57 @@
+import { useCallback, useRef, type MouseEvent, type TouchEvent } from "react";
+
+/** Options for {@link useLongPress}. */
+export interface UseLongPressOptions {
+ /** How long the press must be held before firing, in ms. Defaults to `400`. */
+ delay?: number;
+}
+
+/** Handlers returned by {@link useLongPress}, spread onto the target element. */
+export interface UseLongPressHandlers {
+ onMouseDown: (event: MouseEvent) => void;
+ onMouseUp: () => void;
+ onMouseLeave: () => void;
+ onTouchStart: (event: TouchEvent) => void;
+ onTouchEnd: () => void;
+}
+
+/**
+ * Detects a long press (mouse or touch). Spread the returned handlers onto an
+ * element; `callback` fires once the press is held for `delay` ms, and is
+ * cancelled if the pointer is released or leaves first.
+ *
+ * @param callback - Called with the originating event once the press is held.
+ * @param options - `{ delay }`.
+ * @returns Event handlers to spread onto the target element.
+ */
+export const useLongPress = (
+ callback: (event: MouseEvent | TouchEvent) => void,
+ options: UseLongPressOptions = {},
+): UseLongPressHandlers => {
+ const { delay = 400 } = options;
+ const timeout = useRef | null>(null);
+ const callbackRef = useRef(callback);
+ callbackRef.current = callback;
+
+ const start = useCallback(
+ (event: MouseEvent | TouchEvent) => {
+ timeout.current = setTimeout(() => callbackRef.current(event), delay);
+ },
+ [delay],
+ );
+
+ const cancel = useCallback(() => {
+ if (timeout.current) {
+ clearTimeout(timeout.current);
+ timeout.current = null;
+ }
+ }, []);
+
+ return {
+ onMouseDown: start,
+ onMouseUp: cancel,
+ onMouseLeave: cancel,
+ onTouchStart: start,
+ onTouchEnd: cancel,
+ };
+};
diff --git a/packages/hookli/src/hooks/use-mutation/index.ts b/packages/hookli/src/hooks/use-mutation/index.ts
new file mode 100644
index 0000000..d2b7243
--- /dev/null
+++ b/packages/hookli/src/hooks/use-mutation/index.ts
@@ -0,0 +1 @@
+export * from "./use-mutation";
diff --git a/packages/hookli/src/hooks/use-mutation/use-mutation.test.ts b/packages/hookli/src/hooks/use-mutation/use-mutation.test.ts
new file mode 100644
index 0000000..f32c63b
--- /dev/null
+++ b/packages/hookli/src/hooks/use-mutation/use-mutation.test.ts
@@ -0,0 +1,45 @@
+import { act, renderHook } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { useMutation } from "./use-mutation";
+
+describe("useMutation", () => {
+ it("starts idle", () => {
+ const { result } = renderHook(() => useMutation(async (n: number) => n));
+ expect(result.current.status).toBe("idle");
+ expect(result.current.data).toBeNull();
+ });
+
+ it("runs the mutation and tracks success", async () => {
+ const { result } = renderHook(() => useMutation(async (n: number) => n * 2));
+ await act(async () => {
+ const value = await result.current.mutate(21);
+ expect(value).toBe(42);
+ });
+ expect(result.current.status).toBe("success");
+ expect(result.current.data).toBe(42);
+ });
+
+ it("captures errors without throwing", async () => {
+ const { result } = renderHook(() =>
+ useMutation(async () => {
+ throw new Error("nope");
+ }),
+ );
+ await act(async () => {
+ const value = await result.current.mutate();
+ expect(value).toBeUndefined();
+ });
+ expect(result.current.status).toBe("error");
+ expect(result.current.error?.message).toBe("nope");
+ });
+
+ it("resets", async () => {
+ const { result } = renderHook(() => useMutation(async (n: number) => n));
+ await act(async () => {
+ await result.current.mutate(1);
+ });
+ act(() => result.current.reset());
+ expect(result.current.status).toBe("idle");
+ expect(result.current.data).toBeNull();
+ });
+});
diff --git a/packages/hookli/src/hooks/use-mutation/use-mutation.ts b/packages/hookli/src/hooks/use-mutation/use-mutation.ts
new file mode 100644
index 0000000..eb248cf
--- /dev/null
+++ b/packages/hookli/src/hooks/use-mutation/use-mutation.ts
@@ -0,0 +1,70 @@
+import { useCallback, useRef, useState } from "react";
+
+/** The lifecycle status of a {@link useMutation}. */
+export type MutationStatus = "idle" | "loading" | "success" | "error";
+
+/**
+ * The value returned by {@link useMutation}.
+ */
+export interface UseMutationReturn {
+ /** Run the mutation. Resolves with the data, or `undefined` if it errored. */
+ mutate: (...args: TArgs) => Promise;
+ /** Current lifecycle status. */
+ status: MutationStatus;
+ /** Convenience flag for `status === "loading"`. */
+ isLoading: boolean;
+ /** Data from the last successful run, or `null`. */
+ data: TData | null;
+ /** Error from the last failed run, or `null`. */
+ error: Error | null;
+ /** Reset back to the idle state. */
+ reset: () => void;
+}
+
+/**
+ * Runs an async write action (create/update/delete) on demand and tracks its
+ * `status`, `data`, and `error` — the write-side companion to {@link useAsync}.
+ * `mutate` never throws: on failure it records the error and resolves to
+ * `undefined`. State is not set after unmount.
+ *
+ * @param mutationFn - The async function to run when `mutate` is called.
+ * @returns `{ mutate, status, isLoading, data, error, reset }`.
+ */
+export const useMutation = (
+ mutationFn: (...args: TArgs) => Promise,
+): UseMutationReturn => {
+ const [status, setStatus] = useState("idle");
+ const [data, setData] = useState(null);
+ const [error, setError] = useState(null);
+
+ const fnRef = useRef(mutationFn);
+ fnRef.current = mutationFn;
+ const mounted = useRef(true);
+
+ const mutate = useCallback(async (...args: TArgs) => {
+ setStatus("loading");
+ setError(null);
+ try {
+ const result = await fnRef.current(...args);
+ if (mounted.current) {
+ setData(result);
+ setStatus("success");
+ }
+ return result;
+ } catch (err) {
+ if (mounted.current) {
+ setError(err instanceof Error ? err : new Error(String(err)));
+ setStatus("error");
+ }
+ return undefined;
+ }
+ }, []);
+
+ const reset = useCallback(() => {
+ setStatus("idle");
+ setData(null);
+ setError(null);
+ }, []);
+
+ return { mutate, status, isLoading: status === "loading", data, error, reset };
+};
diff --git a/packages/hookli/src/hooks/use-network-state/index.ts b/packages/hookli/src/hooks/use-network-state/index.ts
new file mode 100644
index 0000000..6b751fa
--- /dev/null
+++ b/packages/hookli/src/hooks/use-network-state/index.ts
@@ -0,0 +1 @@
+export * from "./use-network-state";
diff --git a/packages/hookli/src/hooks/use-network-state/use-network-state.test.ts b/packages/hookli/src/hooks/use-network-state/use-network-state.test.ts
new file mode 100644
index 0000000..cf47515
--- /dev/null
+++ b/packages/hookli/src/hooks/use-network-state/use-network-state.test.ts
@@ -0,0 +1,25 @@
+import { act, renderHook } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { useNetworkState } from "./use-network-state";
+
+describe("useNetworkState", () => {
+ afterEach(() => vi.restoreAllMocks());
+
+ it("reports the current online status", () => {
+ vi.spyOn(navigator, "onLine", "get").mockReturnValue(true);
+ const { result } = renderHook(() => useNetworkState());
+ expect(result.current.online).toBe(true);
+ });
+
+ it("updates when the browser goes offline", () => {
+ const spy = vi.spyOn(navigator, "onLine", "get").mockReturnValue(true);
+ const { result } = renderHook(() => useNetworkState());
+ expect(result.current.online).toBe(true);
+
+ act(() => {
+ spy.mockReturnValue(false);
+ window.dispatchEvent(new Event("offline"));
+ });
+ expect(result.current.online).toBe(false);
+ });
+});
diff --git a/packages/hookli/src/hooks/use-network-state/use-network-state.ts b/packages/hookli/src/hooks/use-network-state/use-network-state.ts
new file mode 100644
index 0000000..19c038f
--- /dev/null
+++ b/packages/hookli/src/hooks/use-network-state/use-network-state.ts
@@ -0,0 +1,76 @@
+import { useEffect, useState } from "react";
+
+/**
+ * The network snapshot returned by {@link useNetworkState}. Connection details
+ * come from the Network Information API and are `undefined` where unsupported.
+ */
+export interface NetworkState {
+ /** Whether the browser is online (`navigator.onLine`). */
+ online: boolean;
+ /** Effective connection type, e.g. `"4g"` (if supported). */
+ effectiveType?: string;
+ /** Downlink speed estimate in Mbps (if supported). */
+ downlink?: number;
+ /** Round-trip time estimate in ms (if supported). */
+ rtt?: number;
+ /** Whether the user requested reduced data usage (if supported). */
+ saveData?: boolean;
+}
+
+type ConnectionLike = {
+ effectiveType?: string;
+ downlink?: number;
+ rtt?: number;
+ saveData?: boolean;
+ addEventListener?: (type: "change", listener: () => void) => void;
+ removeEventListener?: (type: "change", listener: () => void) => void;
+};
+
+const getConnection = (): ConnectionLike | undefined => {
+ if (typeof navigator === "undefined") return undefined;
+ return (
+ (navigator as unknown as { connection?: ConnectionLike }).connection ??
+ undefined
+ );
+};
+
+const read = (): NetworkState => {
+ if (typeof navigator === "undefined") return { online: true };
+ const c = getConnection();
+ return {
+ online: navigator.onLine,
+ effectiveType: c?.effectiveType,
+ downlink: c?.downlink,
+ rtt: c?.rtt,
+ saveData: c?.saveData,
+ };
+};
+
+/**
+ * Tracks the browser's network state — online/offline plus connection details
+ * where the Network Information API is available. Listeners attach in an effect,
+ * so it is SSR-safe (returns `{ online: true }` on the server).
+ *
+ * @returns The current {@link NetworkState}.
+ */
+export const useNetworkState = (): NetworkState => {
+ const [state, setState] = useState(() =>
+ typeof navigator === "undefined" ? { online: true } : read(),
+ );
+
+ useEffect(() => {
+ const update = () => setState(read());
+ update();
+ window.addEventListener("online", update);
+ window.addEventListener("offline", update);
+ const c = getConnection();
+ c?.addEventListener?.("change", update);
+ return () => {
+ window.removeEventListener("online", update);
+ window.removeEventListener("offline", update);
+ c?.removeEventListener?.("change", update);
+ };
+ }, []);
+
+ return state;
+};
diff --git a/packages/hookli/src/hooks/use-page-visibility/index.ts b/packages/hookli/src/hooks/use-page-visibility/index.ts
new file mode 100644
index 0000000..c14ef49
--- /dev/null
+++ b/packages/hookli/src/hooks/use-page-visibility/index.ts
@@ -0,0 +1 @@
+export * from "./use-page-visibility";
diff --git a/packages/hookli/src/hooks/use-page-visibility/use-page-visibility.test.ts b/packages/hookli/src/hooks/use-page-visibility/use-page-visibility.test.ts
new file mode 100644
index 0000000..7a31d0f
--- /dev/null
+++ b/packages/hookli/src/hooks/use-page-visibility/use-page-visibility.test.ts
@@ -0,0 +1,21 @@
+import { act, renderHook } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { usePageVisibility } from "./use-page-visibility";
+
+describe("usePageVisibility", () => {
+ afterEach(() => vi.restoreAllMocks());
+
+ it("is visible by default", () => {
+ const { result } = renderHook(() => usePageVisibility());
+ expect(result.current).toBe(true);
+ });
+
+ it("reacts to visibilitychange", () => {
+ const { result } = renderHook(() => usePageVisibility());
+ act(() => {
+ vi.spyOn(document, "visibilityState", "get").mockReturnValue("hidden");
+ document.dispatchEvent(new Event("visibilitychange"));
+ });
+ expect(result.current).toBe(false);
+ });
+});
diff --git a/packages/hookli/src/hooks/use-page-visibility/use-page-visibility.ts b/packages/hookli/src/hooks/use-page-visibility/use-page-visibility.ts
new file mode 100644
index 0000000..03fc7b5
--- /dev/null
+++ b/packages/hookli/src/hooks/use-page-visibility/use-page-visibility.ts
@@ -0,0 +1,21 @@
+import { useEffect, useState } from "react";
+
+/**
+ * Tracks whether the page/tab is currently visible via the Page Visibility API
+ * (`document.visibilityState`). The listener attaches in an effect, so it is
+ * SSR-safe (returns `true` on the server).
+ *
+ * @returns `true` while the tab is visible, `false` when hidden/backgrounded.
+ */
+export const usePageVisibility = (): boolean => {
+ const [visible, setVisible] = useState(true);
+
+ useEffect(() => {
+ const update = () => setVisible(document.visibilityState === "visible");
+ update();
+ document.addEventListener("visibilitychange", update);
+ return () => document.removeEventListener("visibilitychange", update);
+ }, []);
+
+ return visible;
+};
diff --git a/packages/hookli/src/hooks/use-pagination/index.ts b/packages/hookli/src/hooks/use-pagination/index.ts
new file mode 100644
index 0000000..9853d85
--- /dev/null
+++ b/packages/hookli/src/hooks/use-pagination/index.ts
@@ -0,0 +1 @@
+export * from "./use-pagination";
diff --git a/packages/hookli/src/hooks/use-pagination/use-pagination.test.ts b/packages/hookli/src/hooks/use-pagination/use-pagination.test.ts
new file mode 100644
index 0000000..45bb5a8
--- /dev/null
+++ b/packages/hookli/src/hooks/use-pagination/use-pagination.test.ts
@@ -0,0 +1,37 @@
+import { act, renderHook } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { usePagination } from "./use-pagination";
+
+describe("usePagination", () => {
+ it("computes total pages and the current range", () => {
+ const { result } = renderHook(() => usePagination({ total: 95, pageSize: 10 }));
+ expect(result.current.totalPages).toBe(10);
+ expect(result.current.page).toBe(1);
+ expect(result.current.canPrev).toBe(false);
+ expect(result.current.range).toEqual({ start: 0, end: 10 });
+ });
+
+ it("navigates and clamps", () => {
+ const { result } = renderHook(() => usePagination({ total: 95, pageSize: 10 }));
+
+ act(() => result.current.next());
+ expect(result.current.page).toBe(2);
+ expect(result.current.range).toEqual({ start: 10, end: 20 });
+
+ act(() => result.current.last());
+ expect(result.current.page).toBe(10);
+ expect(result.current.canNext).toBe(false);
+ expect(result.current.range).toEqual({ start: 90, end: 95 });
+
+ act(() => result.current.setPage(999));
+ expect(result.current.page).toBe(10);
+
+ act(() => result.current.first());
+ expect(result.current.page).toBe(1);
+ });
+
+ it("always has at least one page", () => {
+ const { result } = renderHook(() => usePagination());
+ expect(result.current.totalPages).toBe(1);
+ });
+});
diff --git a/packages/hookli/src/hooks/use-pagination/use-pagination.ts b/packages/hookli/src/hooks/use-pagination/use-pagination.ts
new file mode 100644
index 0000000..6fe0804
--- /dev/null
+++ b/packages/hookli/src/hooks/use-pagination/use-pagination.ts
@@ -0,0 +1,96 @@
+import { useCallback, useMemo, useState } from "react";
+
+/** Options for {@link usePagination}. */
+export interface UsePaginationOptions {
+ /** Total number of items. Defaults to `0`. */
+ total?: number;
+ /** Items per page. Defaults to `10`. */
+ pageSize?: number;
+ /** Starting page (1-indexed). Defaults to `1`. */
+ initialPage?: number;
+}
+
+/** The value returned by {@link usePagination}. */
+export interface UsePaginationReturn {
+ /** Current page (1-indexed). */
+ page: number;
+ /** Items per page. */
+ pageSize: number;
+ /** Total number of pages (always ≥ 1). */
+ totalPages: number;
+ /** Jump to a page (clamped to `[1, totalPages]`). */
+ setPage: (page: number) => void;
+ /** Go to the next page (clamped). */
+ next: () => void;
+ /** Go to the previous page (clamped). */
+ prev: () => void;
+ /** Jump to the first page. */
+ first: () => void;
+ /** Jump to the last page. */
+ last: () => void;
+ /** Whether a previous page exists. */
+ canPrev: boolean;
+ /** Whether a next page exists. */
+ canNext: boolean;
+ /** Zero-based item index range for the current page: `[start, end)`. */
+ range: { start: number; end: number };
+}
+
+const clamp = (value: number, min: number, max: number): number =>
+ Math.min(Math.max(value, min), max);
+
+/**
+ * Manages 1-indexed pagination state — page, page size, total pages, navigation
+ * helpers, and the current item `range`. Pure state math; no data fetching.
+ *
+ * @param options - `{ total, pageSize, initialPage }`.
+ * @returns Pagination state and navigation helpers.
+ */
+export const usePagination = (
+ options: UsePaginationOptions = {},
+): UsePaginationReturn => {
+ const { total = 0, pageSize = 10, initialPage = 1 } = options;
+ const totalPages = Math.max(1, Math.ceil(total / pageSize));
+
+ const [page, setPageState] = useState(() =>
+ clamp(initialPage, 1, totalPages),
+ );
+
+ const setPage = useCallback(
+ (next: number) => setPageState(clamp(next, 1, totalPages)),
+ [totalPages],
+ );
+ const next = useCallback(
+ () => setPageState((p) => clamp(p + 1, 1, totalPages)),
+ [totalPages],
+ );
+ const prev = useCallback(
+ () => setPageState((p) => clamp(p - 1, 1, totalPages)),
+ [totalPages],
+ );
+ const first = useCallback(() => setPageState(1), []);
+ const last = useCallback(() => setPageState(totalPages), [totalPages]);
+
+ const current = clamp(page, 1, totalPages);
+ const range = useMemo(
+ () => ({
+ start: (current - 1) * pageSize,
+ end: Math.min(current * pageSize, total),
+ }),
+ [current, pageSize, total],
+ );
+
+ return {
+ page: current,
+ pageSize,
+ totalPages,
+ setPage,
+ next,
+ prev,
+ first,
+ last,
+ canPrev: current > 1,
+ canNext: current < totalPages,
+ range,
+ };
+};
diff --git a/packages/hookli/src/hooks/use-permission/index.ts b/packages/hookli/src/hooks/use-permission/index.ts
new file mode 100644
index 0000000..9ecc09a
--- /dev/null
+++ b/packages/hookli/src/hooks/use-permission/index.ts
@@ -0,0 +1 @@
+export * from "./use-permission";
diff --git a/packages/hookli/src/hooks/use-permission/use-permission.test.ts b/packages/hookli/src/hooks/use-permission/use-permission.test.ts
new file mode 100644
index 0000000..4bd5edb
--- /dev/null
+++ b/packages/hookli/src/hooks/use-permission/use-permission.test.ts
@@ -0,0 +1,14 @@
+import { renderHook, waitFor } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { usePermission } from "./use-permission";
+
+describe("usePermission", () => {
+ it("resolves to a valid state (unsupported under jsdom)", async () => {
+ const { result } = renderHook(() => usePermission("geolocation"));
+ await waitFor(() =>
+ expect(["granted", "denied", "prompt", "unsupported"]).toContain(
+ result.current,
+ ),
+ );
+ });
+});
diff --git a/packages/hookli/src/hooks/use-permission/use-permission.ts b/packages/hookli/src/hooks/use-permission/use-permission.ts
new file mode 100644
index 0000000..11675ab
--- /dev/null
+++ b/packages/hookli/src/hooks/use-permission/use-permission.ts
@@ -0,0 +1,52 @@
+import { useEffect, useState } from "react";
+
+/**
+ * The result of {@link usePermission}: a standard `PermissionState`
+ * (`"granted" | "denied" | "prompt"`), `"pending"` before the first read, or
+ * `"unsupported"` where the Permissions API is unavailable.
+ */
+export type UsePermissionResult = PermissionState | "pending" | "unsupported";
+
+/**
+ * Queries the Permissions API for a permission and tracks its state reactively
+ * (it updates if the user later changes the setting). SSR-safe; resolves to
+ * `"unsupported"` where the API — or the specific permission — is unavailable.
+ *
+ * @param name - The permission to query, e.g. `"geolocation"`, `"camera"`, `"notifications"`.
+ * @returns The current permission state.
+ */
+export const usePermission = (name: PermissionName): UsePermissionResult => {
+ const [state, setState] = useState("pending");
+
+ useEffect(() => {
+ if (typeof navigator === "undefined" || !navigator.permissions) {
+ setState("unsupported");
+ return;
+ }
+
+ let status: PermissionStatus | null = null;
+ let cancelled = false;
+ const onChange = () => {
+ if (status) setState(status.state);
+ };
+
+ navigator.permissions
+ .query({ name })
+ .then((result) => {
+ if (cancelled) return;
+ status = result;
+ setState(result.state);
+ result.addEventListener("change", onChange);
+ })
+ .catch(() => {
+ if (!cancelled) setState("unsupported");
+ });
+
+ return () => {
+ cancelled = true;
+ status?.removeEventListener("change", onChange);
+ };
+ }, [name]);
+
+ return state;
+};
diff --git a/packages/hookli/src/hooks/use-previous/index.ts b/packages/hookli/src/hooks/use-previous/index.ts
new file mode 100644
index 0000000..f597283
--- /dev/null
+++ b/packages/hookli/src/hooks/use-previous/index.ts
@@ -0,0 +1 @@
+export * from "./use-previous";
diff --git a/packages/hookli/src/hooks/use-previous/use-previous.test.ts b/packages/hookli/src/hooks/use-previous/use-previous.test.ts
new file mode 100644
index 0000000..4d64ac4
--- /dev/null
+++ b/packages/hookli/src/hooks/use-previous/use-previous.test.ts
@@ -0,0 +1,20 @@
+import { renderHook } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { usePrevious } from "./use-previous";
+
+describe("usePrevious", () => {
+ it("is undefined on the first render", () => {
+ const { result } = renderHook(() => usePrevious(0));
+ expect(result.current).toBeUndefined();
+ });
+
+ it("returns the value from the previous render", () => {
+ const { result, rerender } = renderHook(({ v }) => usePrevious(v), {
+ initialProps: { v: 0 },
+ });
+ rerender({ v: 1 });
+ expect(result.current).toBe(0);
+ rerender({ v: 2 });
+ expect(result.current).toBe(1);
+ });
+});
diff --git a/packages/hookli/src/hooks/use-previous/use-previous.ts b/packages/hookli/src/hooks/use-previous/use-previous.ts
new file mode 100644
index 0000000..808ad26
--- /dev/null
+++ b/packages/hookli/src/hooks/use-previous/use-previous.ts
@@ -0,0 +1,19 @@
+import { useEffect, useRef } from "react";
+
+/**
+ * Tracks the value from the previous render.
+ *
+ * Returns `undefined` on the first render, then — on every render after — the
+ * value the hook was called with on the render before. The ref is written in an
+ * effect, so it always reflects the last committed value.
+ *
+ * @param value - The value to track across renders.
+ * @returns The value from the previous render (`undefined` on the first render).
+ */
+export const usePrevious = (value: T): T | undefined => {
+ const ref = useRef(undefined);
+ useEffect(() => {
+ ref.current = value;
+ }, [value]);
+ return ref.current;
+};
diff --git a/packages/hookli/src/hooks/use-queue/index.ts b/packages/hookli/src/hooks/use-queue/index.ts
new file mode 100644
index 0000000..a52d97b
--- /dev/null
+++ b/packages/hookli/src/hooks/use-queue/index.ts
@@ -0,0 +1 @@
+export * from "./use-queue";
diff --git a/packages/hookli/src/hooks/use-queue/use-queue.test.ts b/packages/hookli/src/hooks/use-queue/use-queue.test.ts
new file mode 100644
index 0000000..d53896c
--- /dev/null
+++ b/packages/hookli/src/hooks/use-queue/use-queue.test.ts
@@ -0,0 +1,29 @@
+import { act, renderHook } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { useQueue } from "./use-queue";
+
+describe("useQueue", () => {
+ it("adds and removes in FIFO order", () => {
+ const { result } = renderHook(() => useQueue([1]));
+ act(() => result.current.add(2));
+ act(() => result.current.add(3));
+ expect(result.current.queue).toEqual([1, 2, 3]);
+ expect(result.current.first).toBe(1);
+ expect(result.current.last).toBe(3);
+ expect(result.current.size).toBe(3);
+
+ let removed: number | undefined;
+ act(() => {
+ removed = result.current.remove();
+ });
+ expect(removed).toBe(1);
+ expect(result.current.queue).toEqual([2, 3]);
+ });
+
+ it("clears", () => {
+ const { result } = renderHook(() => useQueue([1, 2]));
+ act(() => result.current.clear());
+ expect(result.current.queue).toEqual([]);
+ expect(result.current.first).toBeUndefined();
+ });
+});
diff --git a/packages/hookli/src/hooks/use-queue/use-queue.ts b/packages/hookli/src/hooks/use-queue/use-queue.ts
new file mode 100644
index 0000000..eeeea0a
--- /dev/null
+++ b/packages/hookli/src/hooks/use-queue/use-queue.ts
@@ -0,0 +1,50 @@
+import { useCallback, useRef, useState } from "react";
+
+/** The value returned by {@link useQueue}. */
+export interface UseQueueReturn {
+ /** The current queue, oldest item first. */
+ queue: T[];
+ /** Enqueue an item at the end. */
+ add: (item: T) => void;
+ /** Dequeue the oldest item and return it (`undefined` if empty). */
+ remove: () => T | undefined;
+ /** Empty the queue. */
+ clear: () => void;
+ /** The oldest item (next to be removed), or `undefined`. */
+ first: T | undefined;
+ /** The newest item, or `undefined`. */
+ last: T | undefined;
+ /** Number of items in the queue. */
+ size: number;
+}
+
+/**
+ * Manages a FIFO queue with `add` (enqueue) and `remove` (dequeue) helpers.
+ * `remove` returns the item it removed. Every mutation produces a new array.
+ *
+ * @param initial - Initial items. Defaults to `[]`.
+ * @returns `{ queue, add, remove, clear, first, last, size }`.
+ */
+export const useQueue = (initial: T[] = []): UseQueueReturn => {
+ const [queue, setQueue] = useState(initial);
+ const latest = useRef(queue);
+ latest.current = queue;
+
+ const add = useCallback((item: T) => setQueue((q) => [...q, item]), []);
+ const remove = useCallback((): T | undefined => {
+ const removed = latest.current[0];
+ setQueue((q) => q.slice(1));
+ return removed;
+ }, []);
+ const clear = useCallback(() => setQueue([]), []);
+
+ return {
+ queue,
+ add,
+ remove,
+ clear,
+ first: queue[0],
+ last: queue[queue.length - 1],
+ size: queue.length,
+ };
+};
diff --git a/packages/hookli/src/hooks/use-raf-state/index.ts b/packages/hookli/src/hooks/use-raf-state/index.ts
new file mode 100644
index 0000000..78d1cae
--- /dev/null
+++ b/packages/hookli/src/hooks/use-raf-state/index.ts
@@ -0,0 +1 @@
+export * from "./use-raf-state";
diff --git a/packages/hookli/src/hooks/use-raf-state/use-raf-state.test.ts b/packages/hookli/src/hooks/use-raf-state/use-raf-state.test.ts
new file mode 100644
index 0000000..d43b13c
--- /dev/null
+++ b/packages/hookli/src/hooks/use-raf-state/use-raf-state.test.ts
@@ -0,0 +1,20 @@
+import { act, renderHook } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { useRafState } from "./use-raf-state";
+
+describe("useRafState", () => {
+ afterEach(() => vi.unstubAllGlobals());
+
+ it("updates state on the next frame", () => {
+ vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
+ cb(0);
+ return 1;
+ });
+ vi.stubGlobal("cancelAnimationFrame", () => {});
+
+ const { result } = renderHook(() => useRafState(0));
+ expect(result.current[0]).toBe(0);
+ act(() => result.current[1](5));
+ expect(result.current[0]).toBe(5);
+ });
+});
diff --git a/packages/hookli/src/hooks/use-raf-state/use-raf-state.ts b/packages/hookli/src/hooks/use-raf-state/use-raf-state.ts
new file mode 100644
index 0000000..d9afc07
--- /dev/null
+++ b/packages/hookli/src/hooks/use-raf-state/use-raf-state.ts
@@ -0,0 +1,33 @@
+import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react";
+
+/**
+ * A `useState` whose updates are batched to the next animation frame. Useful for
+ * high-frequency values (scroll, pointer, resize) so React re-renders at most
+ * once per frame. Any pending frame is cancelled on unmount.
+ *
+ * @param initialState - Initial state, or a lazy initializer.
+ * @returns A `[state, setState]` tuple, same shape as `useState`.
+ */
+export const useRafState = (
+ initialState: T | (() => T),
+): [T, Dispatch>] => {
+ const [state, setState] = useState(initialState);
+ const frame = useRef(null);
+
+ const setRafState = useCallback((value: SetStateAction) => {
+ if (frame.current !== null) cancelAnimationFrame(frame.current);
+ frame.current = requestAnimationFrame(() => {
+ frame.current = null;
+ setState(value);
+ });
+ }, []);
+
+ useEffect(
+ () => () => {
+ if (frame.current !== null) cancelAnimationFrame(frame.current);
+ },
+ [],
+ );
+
+ return [state, setRafState];
+};
diff --git a/packages/hookli/src/hooks/use-set/index.ts b/packages/hookli/src/hooks/use-set/index.ts
new file mode 100644
index 0000000..1aab43a
--- /dev/null
+++ b/packages/hookli/src/hooks/use-set/index.ts
@@ -0,0 +1 @@
+export * from "./use-set";
diff --git a/packages/hookli/src/hooks/use-set/use-set.test.ts b/packages/hookli/src/hooks/use-set/use-set.test.ts
new file mode 100644
index 0000000..b28b871
--- /dev/null
+++ b/packages/hookli/src/hooks/use-set/use-set.test.ts
@@ -0,0 +1,34 @@
+import { act, renderHook } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { useSet } from "./use-set";
+
+describe("useSet", () => {
+ it("initialises from an iterable", () => {
+ const { result } = renderHook(() => useSet([1, 2]));
+ expect([...result.current[0]]).toEqual([1, 2]);
+ });
+
+ it("add / remove / toggle / has", () => {
+ const { result } = renderHook(() => useSet());
+
+ act(() => result.current[1].add(1));
+ expect(result.current[1].has(1)).toBe(true);
+
+ act(() => result.current[1].toggle(1));
+ expect(result.current[1].has(1)).toBe(false);
+
+ act(() => result.current[1].toggle(2));
+ expect(result.current[1].has(2)).toBe(true);
+
+ act(() => result.current[1].remove(2));
+ expect([...result.current[0]]).toEqual([]);
+ });
+
+ it("clear and reset", () => {
+ const { result } = renderHook(() => useSet([1, 2]));
+ act(() => result.current[1].clear());
+ expect(result.current[0].size).toBe(0);
+ act(() => result.current[1].reset());
+ expect([...result.current[0]]).toEqual([1, 2]);
+ });
+});
diff --git a/packages/hookli/src/hooks/use-set/use-set.ts b/packages/hookli/src/hooks/use-set/use-set.ts
new file mode 100644
index 0000000..32ab3ea
--- /dev/null
+++ b/packages/hookli/src/hooks/use-set/use-set.ts
@@ -0,0 +1,63 @@
+import { useMemo, useRef, useState } from "react";
+
+/**
+ * Actions returned by {@link useSet} for manipulating the Set immutably.
+ */
+export interface UseSetActions {
+ /** Add a value (no-op if already present). */
+ add: (value: T) => void;
+ /** Remove a value (no-op if absent). */
+ remove: (value: T) => void;
+ /** Add the value if missing, remove it if present. */
+ toggle: (value: T) => void;
+ /** Whether the value is currently in the set (reads the latest set). */
+ has: (value: T) => boolean;
+ /** Empty the set. */
+ clear: () => void;
+ /** Reset to the initial values the hook was created with. */
+ reset: () => void;
+}
+
+/**
+ * Manages `Set` state with immutable helpers (`add`, `remove`, `toggle`, `has`,
+ * `clear`, `reset`). Mutating helpers create a new `Set` so React re-renders,
+ * and `add`/`remove` return the previous set unchanged when nothing changes to
+ * avoid needless renders. `has` always reads the latest value.
+ *
+ * @param initial - Initial values (any iterable). Defaults to empty.
+ * @returns A tuple of `[set, actions]`.
+ */
+export const useSet = (initial?: Iterable): [Set, UseSetActions] => {
+ const [set, setSet] = useState>(() => new Set(initial));
+ const latest = useRef(set);
+ latest.current = set;
+
+ const actions = useMemo>(
+ () => ({
+ add: (value) =>
+ setSet((prev) => (prev.has(value) ? prev : new Set(prev).add(value))),
+ remove: (value) =>
+ setSet((prev) => {
+ if (!prev.has(value)) return prev;
+ const next = new Set(prev);
+ next.delete(value);
+ return next;
+ }),
+ toggle: (value) =>
+ setSet((prev) => {
+ const next = new Set(prev);
+ if (next.has(value)) next.delete(value);
+ else next.add(value);
+ return next;
+ }),
+ has: (value) => latest.current.has(value),
+ clear: () => setSet(new Set()),
+ reset: () => setSet(new Set(initial)),
+ }),
+ // `initial` is intentionally captured once (reset restores the original set).
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [],
+ );
+
+ return [set, actions];
+};
diff --git a/packages/hookli/src/hooks/use-text-selection/index.ts b/packages/hookli/src/hooks/use-text-selection/index.ts
new file mode 100644
index 0000000..71597c5
--- /dev/null
+++ b/packages/hookli/src/hooks/use-text-selection/index.ts
@@ -0,0 +1 @@
+export * from "./use-text-selection";
diff --git a/packages/hookli/src/hooks/use-text-selection/use-text-selection.test.ts b/packages/hookli/src/hooks/use-text-selection/use-text-selection.test.ts
new file mode 100644
index 0000000..4cff08d
--- /dev/null
+++ b/packages/hookli/src/hooks/use-text-selection/use-text-selection.test.ts
@@ -0,0 +1,10 @@
+import { renderHook } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { useTextSelection } from "./use-text-selection";
+
+describe("useTextSelection", () => {
+ it("starts empty", () => {
+ const { result } = renderHook(() => useTextSelection());
+ expect(result.current).toBe("");
+ });
+});
diff --git a/packages/hookli/src/hooks/use-text-selection/use-text-selection.ts b/packages/hookli/src/hooks/use-text-selection/use-text-selection.ts
new file mode 100644
index 0000000..7d44028
--- /dev/null
+++ b/packages/hookli/src/hooks/use-text-selection/use-text-selection.ts
@@ -0,0 +1,21 @@
+import { useEffect, useState } from "react";
+
+/**
+ * Tracks the text the user has currently selected on the page
+ * (`window.getSelection()`), updating on every `selectionchange`. The listener
+ * attaches in an effect, so it is SSR-safe (returns `""` on the server).
+ *
+ * @returns The selected text, or `""` when nothing is selected.
+ */
+export const useTextSelection = (): string => {
+ const [text, setText] = useState("");
+
+ useEffect(() => {
+ const update = () => setText(window.getSelection()?.toString() ?? "");
+ update();
+ document.addEventListener("selectionchange", update);
+ return () => document.removeEventListener("selectionchange", update);
+ }, []);
+
+ return text;
+};
diff --git a/packages/hookli/src/hooks/use-throttle/index.ts b/packages/hookli/src/hooks/use-throttle/index.ts
new file mode 100644
index 0000000..eb2a4f9
--- /dev/null
+++ b/packages/hookli/src/hooks/use-throttle/index.ts
@@ -0,0 +1 @@
+export * from "./use-throttle";
diff --git a/packages/hookli/src/hooks/use-throttle/use-throttle.test.ts b/packages/hookli/src/hooks/use-throttle/use-throttle.test.ts
new file mode 100644
index 0000000..5dc50a6
--- /dev/null
+++ b/packages/hookli/src/hooks/use-throttle/use-throttle.test.ts
@@ -0,0 +1,27 @@
+import { act, renderHook } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { useThrottle } from "./use-throttle";
+
+describe("useThrottle", () => {
+ afterEach(() => vi.useRealTimers());
+
+ it("returns the initial value immediately", () => {
+ const { result } = renderHook(() => useThrottle("a", 500));
+ expect(result.current).toBe("a");
+ });
+
+ it("delays updates to at most once per interval", () => {
+ vi.useFakeTimers();
+ const { result, rerender } = renderHook(({ v }) => useThrottle(v, 500), {
+ initialProps: { v: "a" },
+ });
+
+ rerender({ v: "b" });
+ expect(result.current).toBe("a"); // trailing update still pending
+
+ act(() => {
+ vi.advanceTimersByTime(500);
+ });
+ expect(result.current).toBe("b");
+ });
+});
diff --git a/packages/hookli/src/hooks/use-throttle/use-throttle.ts b/packages/hookli/src/hooks/use-throttle/use-throttle.ts
new file mode 100644
index 0000000..dc12d0b
--- /dev/null
+++ b/packages/hookli/src/hooks/use-throttle/use-throttle.ts
@@ -0,0 +1,38 @@
+import { useEffect, useRef, useState } from "react";
+
+/**
+ * Throttles a fast-changing value: the returned value updates at most once every
+ * `interval` milliseconds. A trailing update is scheduled so the latest value is
+ * never dropped. The timer lives inside an effect, so the hook is SSR-safe and
+ * cleans up on unmount.
+ *
+ * The twin of {@link useDebounce} — throttle emits on a steady cadence while the
+ * value keeps changing; debounce waits for the changes to stop.
+ *
+ * @param value - The value to throttle.
+ * @param interval - Minimum time between updates, in milliseconds. Defaults to `500`.
+ * @returns The throttled value.
+ */
+export const useThrottle = (value: T, interval: number = 500): T => {
+ const [throttled, setThrottled] = useState(value);
+ const lastRan = useRef(Date.now());
+
+ useEffect(() => {
+ const now = Date.now();
+ const remaining = interval - (now - lastRan.current);
+
+ if (remaining <= 0) {
+ lastRan.current = now;
+ setThrottled(value);
+ return;
+ }
+
+ const id = setTimeout(() => {
+ lastRan.current = Date.now();
+ setThrottled(value);
+ }, remaining);
+ return () => clearTimeout(id);
+ }, [value, interval]);
+
+ return throttled;
+};
diff --git a/packages/hookli/src/hooks/use-update-effect/index.ts b/packages/hookli/src/hooks/use-update-effect/index.ts
new file mode 100644
index 0000000..d6224e1
--- /dev/null
+++ b/packages/hookli/src/hooks/use-update-effect/index.ts
@@ -0,0 +1 @@
+export * from "./use-update-effect";
diff --git a/packages/hookli/src/hooks/use-update-effect/use-update-effect.test.ts b/packages/hookli/src/hooks/use-update-effect/use-update-effect.test.ts
new file mode 100644
index 0000000..7206ce5
--- /dev/null
+++ b/packages/hookli/src/hooks/use-update-effect/use-update-effect.test.ts
@@ -0,0 +1,37 @@
+import { renderHook } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { useUpdateEffect } from "./use-update-effect";
+
+describe("useUpdateEffect", () => {
+ it("does not run on the first render", () => {
+ const effect = vi.fn();
+ renderHook(() => useUpdateEffect(effect, [0]));
+ expect(effect).not.toHaveBeenCalled();
+ });
+
+ it("runs on dependency updates", () => {
+ const effect = vi.fn();
+ const { rerender } = renderHook(({ dep }) => useUpdateEffect(effect, [dep]), {
+ initialProps: { dep: 0 },
+ });
+ expect(effect).not.toHaveBeenCalled();
+ rerender({ dep: 1 });
+ expect(effect).toHaveBeenCalledTimes(1);
+ rerender({ dep: 2 });
+ expect(effect).toHaveBeenCalledTimes(2);
+ });
+
+ it("runs the cleanup from the previous run and on unmount", () => {
+ const cleanup = vi.fn();
+ const effect = vi.fn(() => cleanup);
+ const { rerender, unmount } = renderHook(
+ ({ dep }) => useUpdateEffect(effect, [dep]),
+ { initialProps: { dep: 0 } },
+ );
+ rerender({ dep: 1 });
+ rerender({ dep: 2 });
+ expect(cleanup).toHaveBeenCalledTimes(1);
+ unmount();
+ expect(cleanup).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/packages/hookli/src/hooks/use-update-effect/use-update-effect.ts b/packages/hookli/src/hooks/use-update-effect/use-update-effect.ts
new file mode 100644
index 0000000..399f394
--- /dev/null
+++ b/packages/hookli/src/hooks/use-update-effect/use-update-effect.ts
@@ -0,0 +1,30 @@
+import {
+ useEffect,
+ useRef,
+ type DependencyList,
+ type EffectCallback,
+} from "react";
+
+/**
+ * A variant of `useEffect` that **skips the initial mount** and only runs on
+ * subsequent dependency changes. Handy for reacting to updates without the
+ * effect firing once on first render.
+ *
+ * @param effect - Imperative effect callback; may return a cleanup function.
+ * @param deps - Dependency list, with the same semantics as `useEffect`.
+ */
+export const useUpdateEffect = (
+ effect: EffectCallback,
+ deps?: DependencyList,
+): void => {
+ const isFirst = useRef(true);
+
+ useEffect(() => {
+ if (isFirst.current) {
+ isFirst.current = false;
+ return;
+ }
+ return effect();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, deps);
+};
diff --git a/packages/hookli/src/hooks/use-window-scroll/index.ts b/packages/hookli/src/hooks/use-window-scroll/index.ts
new file mode 100644
index 0000000..6f20a1c
--- /dev/null
+++ b/packages/hookli/src/hooks/use-window-scroll/index.ts
@@ -0,0 +1 @@
+export * from "./use-window-scroll";
diff --git a/packages/hookli/src/hooks/use-window-scroll/use-window-scroll.test.ts b/packages/hookli/src/hooks/use-window-scroll/use-window-scroll.test.ts
new file mode 100644
index 0000000..928a302
--- /dev/null
+++ b/packages/hookli/src/hooks/use-window-scroll/use-window-scroll.test.ts
@@ -0,0 +1,20 @@
+import { act, renderHook } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { useWindowScroll } from "./use-window-scroll";
+
+describe("useWindowScroll", () => {
+ it("starts at the current scroll position", () => {
+ const { result } = renderHook(() => useWindowScroll());
+ expect(result.current).toEqual({ x: 0, y: 0 });
+ });
+
+ it("updates on scroll", () => {
+ const { result } = renderHook(() => useWindowScroll());
+ act(() => {
+ Object.defineProperty(window, "scrollX", { value: 10, configurable: true });
+ Object.defineProperty(window, "scrollY", { value: 20, configurable: true });
+ window.dispatchEvent(new Event("scroll"));
+ });
+ expect(result.current).toEqual({ x: 10, y: 20 });
+ });
+});
diff --git a/packages/hookli/src/hooks/use-window-scroll/use-window-scroll.ts b/packages/hookli/src/hooks/use-window-scroll/use-window-scroll.ts
new file mode 100644
index 0000000..0ce9774
--- /dev/null
+++ b/packages/hookli/src/hooks/use-window-scroll/use-window-scroll.ts
@@ -0,0 +1,32 @@
+import { useEffect, useState } from "react";
+
+/**
+ * The window scroll offset reported by {@link useWindowScroll}.
+ */
+export interface WindowScrollPosition {
+ /** Horizontal scroll offset in pixels (`window.scrollX`). */
+ x: number;
+ /** Vertical scroll offset in pixels (`window.scrollY`). */
+ y: number;
+}
+
+/**
+ * Tracks the window's scroll position reactively. The listener is registered in
+ * an effect (SSR-safe) and marked `passive` for scroll performance. Both values
+ * start at `0` and update on every scroll.
+ *
+ * @returns The current `{ x, y }` scroll offset in pixels.
+ */
+export const useWindowScroll = (): WindowScrollPosition => {
+ const [position, setPosition] = useState({ x: 0, y: 0 });
+
+ useEffect(() => {
+ const onScroll = () =>
+ setPosition({ x: window.scrollX, y: window.scrollY });
+ onScroll();
+ window.addEventListener("scroll", onScroll, { passive: true });
+ return () => window.removeEventListener("scroll", onScroll);
+ }, []);
+
+ return position;
+};