Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ CI sharding, MCP extensions).
| Chromium piece | Here | Loaded when |
|-------------------------|----------------------------------------|--------------------------------------|
| `ai_policy.md` | [`ai-policy.md`](./ai-policy.md) | Read it before shipping AI-assisted code |
| API style guide | [`api-design.md`](./api-design.md) | Before adding/changing a public hook signature |
| `common.md` workflow | `## AI Agent System` in root `CLAUDE.md` | Always (project memory) |
| `knowledge_base.md` | [`knowledge-base.md`](./knowledge-base.md) | Always (it's the router) |
| Skills | [`skills/`](./skills) | Auto-activated by relevant requests |
Expand Down
119 changes: 119 additions & 0 deletions .claude/api-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# API Design Spec — `@reactuses/core`

The contract every public hook signature must follow. It adapts *The Little Manual of API
Design* (Blanchette, Trolltech — the Qt design booklet, mirrored at `/papers/api-design` on the
site) to a React-hooks library, and encodes the conventions this repo already follows so new
hooks don't drift. **Read this before adding or changing a public signature.** It is referenced
from `CLAUDE.md` and the `new-hook` skill.

> The manual's five marks of a good API: **easy to learn**, **leads to readable code**, **hard to
> misuse**, **easy to extend**, **complete**. "Minimal" and "consistent" aren't on that list —
> they serve the five. Consistency ≈ *conceptual integrity*: one coherent design, not many good
> but unaligned ideas.

---

## 1. Signature shape (manual §4.11 — property-based APIs)

A hook signature is `(subject, [essential args], options?)`:

```ts
useX(subject, options?) // most hooks
useX(subject, essential, options?) // when one more arg is genuinely required
```

- **Lead with the subject** — the thing the hook operates on (a `target`, a `key`, a `value`,
a `callback`). Not configuration.
- **All configuration goes in a single trailing `options` object**, named `options`, always
last, always optional with sensible defaults. Users set only what they want to change, in any
order, and the call site stays readable without comments.
- **Do not add a 3rd/4th bare positional value.** `useFavicon(href, baseUrl, rel)` is the
anti-pattern (manual's Qt-3 `QSlider(8,128,1,6,…)`): unreadable at the call site. Fold extras
into `options`.
- **`options` position is fixed.** It is always the *last* parameter. A required-config object
in the middle (as `useCookie` once had `options` before `defaultValue`) silently mis-types
arguments and breaks consistency with sibling hooks.

Mirror a native API's positional shape **only** when users already know it
(`useEventListener(event, handler, target, options)` ≈ `addEventListener`) — manual §3.3
(reuse familiar APIs) balanced against §4.6 (don't be a slave to the underlying API).

## 2. Naming (manual §4.1–4.6)

| Rule | Do | Don't |
|---|---|---|
| Self-explanatory, reads like English (§4.1) | `useScroll(target, options)` | one-letter / cryptic params |
| No abbreviations (§4.4), except the common set `min max dir rect prev ref` | `defaultValue` | `defVal`, `def` |
| Specific over general (§4.5) | `UseScrollOptions` | `Options` |
| `set`/`on` prefix means *only* setter/handler (§4.3) | `setValue`, `onScroll` | `set`-prefixing a non-setter |
| No typos — a published name is **locked forever** (§3.8, the `SHStripMneumonic` story) | review names before export | `useWindowsFocus`, `defauleValue` |

**The `default*` vs `initial*` distinction is intentional — keep it** (flattening it would be
the *false consistency* §4.3 warns against):

- **`default*`** — a fallback for state read from the environment, used when the real value is
unavailable (SSR / hydration). Hooks reading external state: `useCookie`, `useLocalStorage`,
`useMediaQuery`, `usePreferredDark`, `useDocumentVisibility`, `useWindowFocus`.
- **`initial*`** — the seed of internal mutable state the hook then owns. Stateful hooks:
`useBoolean`, `useCounter`, `useToggle`, `useMap`.

Suffix: use **`Value`** for a scalar/boolean, **`State`** only when it is a `useState`-style
container (object or generic `S`, as in `useRafState`). Prefer `Value` when unsure.

**DOM target parameter** is always named **`target`** (type `BasicTarget<…>`), never
`targetElement` / `el` / `element`.

## 3. Return shape (manual §2.2 — readable code)

- **Tuple** `[value, setter]` or `[a, b]` — for ≤2 values, or the `useState`-like
`[state, set]` idiom. Idiomatic React; lets callers name positions.
- **Object** `{ named, fields }` — for ≥3 fields, or heterogeneous fields where position would
be ambiguous (manual §4.1 warns positional `[number, number]` hides which is which).
- **Pick one shape per concept and hold it.** Two hooks reporting the same kind of thing must
return the same shape (see the `useElementSize` / `useWindowSize` deviation below).

## 4. Semantics (manual §4.7–4.9)

- **SSR-safe by default (§4.7, §4.12 "the best API is no API").** Never touch `window`/`document`
at module top level. Guard with `isBrowser` / `defaultWindow`; the hook should return a sane
server value with zero extra user code. See `knowledge-base.md`.
- **Boolean options default to `false`** and read affirmatively (`exact`, `preventDefault`),
user opts in.
- **No clever side effects (§4.8).** No hidden format auto-detection, no one setter quietly
flipping unrelated state. Least surprise.
- **Edge cases are covered by tests (§4.9)** — empty/undefined target, unmount, repeated calls.
- **Reuse the underlying API's option type when wrapping it** — `ResizeObserverOptions`,
`IntersectionObserverInit`, `AddEventListenerOptions`, `Cookies.CookieAttributes`. This is
deliberate (§3.3), not an inconsistency.

## 5. Process (manual §3.x)

- Write the call-site example first; let it shape the signature (§3.2).
- Find a sibling hook and match it (§3.3) — this file + `knowledge-base.md` are the router.
- **When in doubt, leave it out (§3.9).** A param renamed later is cheap; a wrong one shipped
is locked. Don't add an option until it's actually needed.

---

## Known deviations (grandfathered)

Real inconsistencies that predate this spec. They are **not** runtime-fixed yet because the fix
is either purely cosmetic (param-name only — non-breaking, converge opportunistically) or a
return-shape change that needs a deliberate major-version decision. Do **not** copy these; do
**not** mass-rewrite them in unrelated PRs.

| Deviation | Hooks | Spec rule | Fix path |
|---|---|---|---|
| `Value`/`State` suffix drift | `defaultState` (×5: `useMediaQuery`, `usePreferred*`, `useReducedMotion`), `initialState` (booleans in `useIdle`, `useScrollLock`) | §2 suffix rule | param-rename, non-breaking — converge incrementally |
| Size returns disagree | `useElementSize` → `[w, h]` tuple vs `useWindowSize` → `{ width, height }` object | §3 one-shape-per-concept | breaking — decide at a major bump |
| Trailing bare positionals | `useFavicon(href, baseUrl, rel)` | §1 no 3rd bare value | fold into `options` at a major bump |
| Required mid-positional config | `useSticky(target, params, scrollElement?)` — `params` required, not named `options`, not last | §1 options-last | reshape at a major bump |

## Checklist for a new/changed public signature

1. Subject first; all config in a trailing optional `options` object.
2. No new bare positional beyond the 2nd argument.
3. Names: no abbreviations, no typos, `default*` vs `initial*` used correctly, DOM target = `target`.
4. Return shape matches the rule **and** any sibling hook of the same concept.
5. SSR-safe with no extra user code; boolean options default `false`.
6. A readable call-site example exists and reads without comments.
1 change: 1 addition & 0 deletions .claude/knowledge-base.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ referenced from `CLAUDE.md`, so treat it as always-in-context.
| When the task involves…​ | Read / do this |
|---|---|
| **Adding or editing a hook** | Conventions live in `packages/core/src/utils/` + existing hooks. Use the **[new-hook](./skills/new-hook/SKILL.md)** skill. Every hook is a folder `packages/core/src/useX/` with `index.ts` + `interface.ts` (+ `index.spec.ts`). |
| **Designing or changing a public signature** | Read **[api-design.md](./api-design.md)** — the contract for parameter order (`(subject, …, options?)`), naming (`default*` vs `initial*`, DOM target = `target`, no abbreviations/typos), return shape (tuple vs object), and SSR defaults. A shipped name is locked forever. |
| **Touching `window` / `document` (SSR safety)** | This is the #1 source of bugs. Use `defaultWindow` / `defaultDocument` from `packages/core/src/utils/browser.ts`, or `isBrowser` / `isNavigator` from `packages/core/src/utils/is.ts`. For subscribed external state, prefer the `use-sync-external-store/shim` pattern with a server fallback (see `useLocationSelector`, `useColorMode`). Never read `window.x` at module top level without a guard. |
| **Attaching event listeners** | Reuse the `useEventListener` hook — do **not** hand-roll `addEventListener`/`removeEventListener`. For non-hook contexts, `on()` / `off()` in `utils/browser.ts` already null-check the target. |
| **Keeping a callback fresh without re-subscribing** | Use `useLatest` (store the latest fn in a ref) to avoid stale closures. |
Expand Down
4 changes: 4 additions & 0 deletions .claude/skills/new-hook/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ built in this repo today. Pair it with **hook-test** and **hook-docs** afterward
3. Check the [knowledge base](../../knowledge-base.md) reuse cheat-sheet — much of what you
need (`useLatest`, `useUnmount`, `useEventListener`, `defaultWindow`, `isBrowser`) is
already written. Reuse before writing.
4. Read **[api-design.md](../../api-design.md)** and apply it to the signature: subject first,
all config in a trailing optional `options` object, DOM target named `target`, `default*`
(SSR fallback) vs `initial*` (mutable seed) used correctly, the right return shape (tuple
vs object), and **no typos — a shipped name is locked forever**.

## Step 1 — Create the hook folder

Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ hooks library, not a 35M-line codebase. Map: [`.claude/README.md`](.claude/READM
**Where to look:**

- [`.claude/knowledge-base.md`](.claude/knowledge-base.md) — task → file/utility router. **Start here**; it tells you which existing code already solves your task.
- [`.claude/api-design.md`](.claude/api-design.md) — the public-signature contract (naming, options object, return shape, SSR defaults). **Read before adding or changing any hook signature.**
- [`.claude/ai-policy.md`](.claude/ai-policy.md) — you own every line you ship; understand it before review.
- Skills (auto-activate): `new-hook`, `hook-test`, `hook-docs`, `pr-description`.
- Commands: `/new-hook`, `/pre-pr`, `/pr-desc`.
Expand Down
24 changes: 24 additions & 0 deletions packages/core/changelog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,29 @@
# ChangeLog

## 7.0.0 (June 30, 2026)

API-design consistency pass (see `.claude/api-design.md`). **Breaking changes** — all are
renames/reorders with no behavior change; migration is mechanical.

- **`useWindowsFocus` → `useWindowFocus`** — the published export had a stray `s`
(the concept is "window focus", not "windows focus"). Its `defauleValue` parameter is
also corrected to `defaultValue`.
```diff
- import { useWindowsFocus } from '@reactuses/core'
+ import { useWindowFocus } from '@reactuses/core'
```
- **`useCookie` argument order** — now `useCookie(key, defaultValue?, options?)`, aligning
with `useLocalStorage` / `useSessionStorage`. Previously `options` came before
`defaultValue`, which silently mis-typed a string passed in the `useLocalStorage` shape.
```diff
- useCookie('token', { path: '/' }, 'guest')
+ useCookie('token', 'guest', { path: '/' })
```
- **`useScrollIntoView` / `useSticky`** — first parameter renamed `targetElement` → `target`,
matching the 20 other DOM-target hooks. Positional callers are unaffected.
- Fixed JSDoc typos surfaced in editor autocomplete (`usePreferredDark`,
`usePreferredLanguages`, `useRafFn`, `useCookie`).

## 1.0.0 (September 18 ,2022)

- Initial public release
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@reactuses/core",
"version": "6.4.0",
"version": "7.0.0",
"description": "Collection of 100+ essential React Hooks with TypeScript support, tree-shaking, and SSR compatibility. Sensors, browser APIs, state management, animations, and more.",
"author": {
"name": "lianwenwu",
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ import { useUpdate } from './useUpdate'
import { useUpdateEffect } from './useUpdateEffect'
import { useUpdateLayoutEffect } from './useUpdateLayoutEffect'
import { useWebNotification } from './useWebNotification'
import { useWindowsFocus } from './useWindowFocus'
import { useWindowFocus } from './useWindowFocus'
import { useWindowScroll } from './useWindowScroll'
import { useWindowSize } from './useWindowSize'
import { useClipboard } from './useClipboard'
Expand Down Expand Up @@ -178,7 +178,7 @@ export {
useDraggable,
useElementBounding,
useElementVisibility,
useWindowsFocus,
useWindowFocus,
useWindowSize,
useWindowScroll,
useClipboard,
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/useCookie/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ describe('useCookie', () => {
options?: Cookies.CookieAttributes,
) =>
renderHook(() => {
const [state, setState, refresh] = useCookie(key, options, defaultValue)
const [state, setState, refresh] = useCookie(key, defaultValue, options)
return {
state,
setState,
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/useCookie/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ function getInitialState(key: string, defaultValue?: string) {

export const useCookie: UseCookie = (
key: string,
options: Cookies.CookieAttributes = defaultOptions,
defaultValue?: string,
options: Cookies.CookieAttributes = defaultOptions,
) => {
const [cookieValue, setCookieValue] = useState<UseCookieState>(
getInitialState(key, defaultValue),
Expand Down
16 changes: 8 additions & 8 deletions packages/core/src/useCookie/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type Cookies from 'js-cookie'
* @returns_en A tuple with the following elements:
* - The current value of the cookie.
* - A function to update the value of the cookie.
* - A function to refresh the value of the cookie, incase other events change it.
* - A function to refresh the value of the cookie, in case other events change it.
* @returns_zh-Hant 包含以下元素的元組:
* - cookie 的當前值。
* - 更新 cookie 值的函數。
Expand All @@ -22,18 +22,18 @@ export type UseCookie = (
* @en key
*/
key: string,
/**
* @zh 透传给 `js-cookie` 的参数
* @zh-Hant 透傳給 `js-cookie` 的參數
* @en option pass to `js-cookie`
*/
options?: Cookies.CookieAttributes,
/**
* @zh 默认值,ssr必须传递
* @zh-Hant 預設值,ssr必須傳遞
* @en defaultValue, must be required in ssr
*/
defaultValue?: string
defaultValue?: string,
/**
* @zh 透传给 `js-cookie` 的参数
* @zh-Hant 透傳給 `js-cookie` 的參數
* @en option pass to `js-cookie`
*/
options?: Cookies.CookieAttributes
) => readonly [
UseCookieState,
(
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/usePreferredDark/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export type UsePreferredDark = (
/**
* @zh 默认值
* @zh-Hant 預設值
* @en defaule value
* @en default value
*/
defaultState?: boolean
) => boolean
2 changes: 1 addition & 1 deletion packages/core/src/usePreferredLanguages/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,6 @@ export type UsePreferredLanguages = (
/**
* @zh 默认值
* @zh-Hant 預設值
* @en defaule value
* @en default value
*/ defaultLanguages?: string[]
) => string[]
2 changes: 1 addition & 1 deletion packages/core/src/useRafFn/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export type UseRafFn = (
callback: FrameRequestCallback,
/**
* @zh 立即执行
* @en immediatly start
* @en immediately start
*/
initiallyActive?: boolean
) => readonly [() => void, () => void, () => boolean]
4 changes: 2 additions & 2 deletions packages/core/src/useScrollIntoView/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import type { UseScrollIntoView, UseScrollIntoViewAnimation, UseScrollIntoViewPa
const listenerOptions = { passive: true }

export const useScrollIntoView: UseScrollIntoView = (
targetElement: BasicTarget<HTMLElement>,
target: BasicTarget<HTMLElement>,
{
duration = 1250,
axis = 'y',
Expand All @@ -40,7 +40,7 @@ export const useScrollIntoView: UseScrollIntoView = (
}
}

const element = getTargetElement(targetElement)
const element = getTargetElement(target)

const scrollIntoView = ({
alignment = 'start',
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/useScrollIntoView/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export type UseScrollIntoView = (
* @zh-Hant dom對象
* @en dom element
*/
targetElement: BasicTarget<HTMLElement>,
target: BasicTarget<HTMLElement>,
/**
* @zh 可选参数
* @en optional params
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/useSticky/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import { useStableTarget } from '../utils/useStableTarget'
import { useLatest } from '../useLatest'
import type { UseStickyParams } from './interface'

export function useSticky(targetElement: BasicTarget<HTMLElement>, { axis = 'y', nav = 0 }: UseStickyParams, scrollElement?: BasicTarget<HTMLElement>): [boolean, React.Dispatch<React.SetStateAction<boolean>>] {
export function useSticky(target: BasicTarget<HTMLElement>, { axis = 'y', nav = 0 }: UseStickyParams, scrollElement?: BasicTarget<HTMLElement>): [boolean, React.Dispatch<React.SetStateAction<boolean>>] {
const [isSticky, setSticky] = useState<boolean>(false)
const { key: targetKey, ref: targetRef } = useStableTarget(targetElement)
const { key: targetKey, ref: targetRef } = useStableTarget(target)
const { key: scrollKey, ref: scrollRef } = useStableTarget(scrollElement)
const axisRef = useLatest(axis)
const navRef = useLatest(nav)
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/useSticky/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export type UseSticky = (
* @zh-Hant dom元素
* @en dom element
*/
targetElement: BasicTarget<HTMLElement>,
target: BasicTarget<HTMLElement>,
/**
* @zh 可选参数
* @en optional params
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/useWindowFocus/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { useEffect, useState } from 'react'
import { useEventListener } from '../useEventListener'

export function useWindowsFocus(defauleValue = false): boolean {
const [focused, setFocused] = useState(defauleValue)
export function useWindowFocus(defaultValue = false): boolean {
const [focused, setFocused] = useState(defaultValue)

useEffect(() => {
setFocused(window.document.hasFocus())
Expand Down
8 changes: 4 additions & 4 deletions packages/core/src/useWindowFocus/interface.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
/**
* @title useWindowsFocus
* @title useWindowFocus
* @returns 窗口是否聚焦
* @returns_en whether window focus
* @returns_zh-Hant 窗口是否聚焦
*/
export type UseWindowsFocus = (
export type UseWindowFocus = (
/**
* @zh 默认值
* @zh-Hant 預設值
* @en defauleValue
* @en default value
*/
defauleValue?: boolean
defaultValue?: boolean
) => boolean
Loading
Loading