# Hooks

> Generated reference (regen-owned; see [index.md](index.md)). Binds only under `primitives: nurix`.

Standalone React hooks, each imported from its own `hooks/*` subpath under `@nurix/components/`, plus the Uppy upload adapter at `lib/uppy-adapter`. Every Base UI-backed surface already owns its dismissal, Escape and roving focus — never double-wire a hook onto one.

## useClickOutside

`useClickOutside(ref, callback)` (from `hooks/use-click-outside`) — fires `callback` on a `document` `mousedown`/`touchstart` outside `ref.current`. Only for a bespoke floating surface with no dismissal of its own.

- Wrap the trigger and the panel in the same ref when the trigger toggles — `mousedown` runs before the trigger's `click`, so an unwrapped trigger closes and reopens the surface.
- No `enabled` option: listeners live for the component's life, so unmount the surface on close or gate inside the callback. A portalled child reads as an outside press.

```tsx
const panelRef = useRef<HTMLDivElement>(null);
useClickOutside(panelRef, () => setOpen(false));
```

## useControllableState

`useControllableState({ prop, defaultProp, onChange })` (from `hooks/use-controllable-state`) — returns `[value, setValue]`, collapsing controlled and uncontrolled into one code path.

- Presence decides the mode: `prop: undefined` is uncontrolled. `onChange(value)` fires only when the value changes (`Object.is`) and takes the value alone — a Base UI `onOpenChange(open, eventDetails)` keeps its own wiring.
- `defaultProp` is read once at mount; hoist an array or object default to a module constant.

```tsx
const [value, setValue] = useControllableState({ prop: valueProp, defaultProp: defaultValue ?? "", onChange: onValueChange });
```

## useDebounce

`useDebounce(value, delayMs)` (from `hooks/use-debounce`) — returns the value, settled `delayMs` after it stops changing. First render returns it as-is; a pending update drops on unmount.

- Debounce the state, not the handler: keep the input controlled and immediate, point the expensive effect at the debounced value. A one-shot delayed action is `useTimeout`.

```tsx
const debouncedQuery = useDebounce(query, 300);
```

## useEventListener

`useEventListener(eventName, handler, target?, options?)` (from `hooks/use-event-listener`) — `target` defaults to `window`; accepts `document`, a node, a ref, or `null` (subscribes nothing).

- The handler is read at fire time, so an inline closure never re-subscribes; `options` is captured at subscribe time. A target that mounts later must be passed as the element via state — a ref's `.current` changing re-renders nothing.

```tsx
useEventListener("mousemove", (event) => setPointer({ x: event.clientX, y: event.clientY }), panelRef);
```

## useFilter

`useFilter(options?)` (from `hooks/use-filter`) — returns `{ contains, startsWith, endsWith }`, each `(value, query) => boolean`, locale-aware via `Intl.Collator`. Options: `locale`, any `Intl.CollatorOptions`, `gapMatch` (default `false`).

- An empty query matches everything. `gapMatch: true` strips separators so "jsmith" matches "J. Smith", and skips the collator's accent folding. `compound/command` filters its own items — a palette needs no matcher.

```tsx
const { contains } = useFilter({ sensitivity: "base" });
const visible = items.filter((item) => contains(item.label, query));
```

## useFreshness

`useFreshness({ fetchedAt?, onRefresh, staleAfterMs?, tickMs? })` (from `hooks/use-freshness`) — returns `{ refresh, isRefreshing, isStale }` for a refresh control.

- `isRefreshing` follows a returned promise (a synchronous `onRefresh` never sets it); a click mid-refresh returns the in-flight promise, no queue. `isStale` needs both `fetchedAt` and `staleAfterMs`, else no clock runs.

```tsx
const { refresh, isRefreshing } = useFreshness({ fetchedAt, onRefresh: refetch, staleAfterMs: 5 * 60_000 });
<Button onClick={refresh} disabled={isRefreshing}>Updated <Timestamp value={fetchedAt} relativeThreshold={Infinity} /></Button>
```

## useIntersectionObserver

`useIntersectionObserver<T>(options?)` (from `hooks/use-intersection-observer`) — returns `[callbackRef, entry]`; options (`root`, `rootMargin`, `threshold`) pass through, and instances with the same root and options share one observer.

- `entry` is `null` until the first observation and is not cleared on unmount. For paging a stream use `block/list`'s `onLoadMore` + `hasMore` — it owns the sentinel and its re-entry guard.

```tsx
const [sentinelRef, entry] = useIntersectionObserver<HTMLDivElement>();
const isAtTop = entry?.isIntersecting ?? false;
```

## useInterval

`useInterval(callback, delayMs | null)` (from `hooks/use-interval`) — `null` pauses; changing the delay restarts the cadence; the callback is read at tick time.

- Stop by state, not `clearInterval`. Ticks are not a clock (background tabs throttle) — read `Date.now()` per tick for elapsed time.

```tsx
useInterval(tick, isDone ? null : speedMs);
```

## useKeyPress

`useKeyPress(key, callback, { event?, target? })` (from `hooks/use-key-press`) — matches `event.key` (case-sensitive); `event` is `"keydown"` (default) or `"keyup"`, `target` defaults to `window`.

- A window listener hears keys typed into inputs — require a modifier or check `event.target`. Base UI popups own their Escape.

```tsx
useKeyPress("b", (event) => { if (event.metaKey || event.ctrlKey) { event.preventDefault(); toggleSidebar(); } });
```

## useMeasure

`useMeasure<T>()` (from `hooks/use-measure`) — returns `[callbackRef, { width, height }]`, `contentRect` sizes, `{ 0, 0 }` until the first observation.

- Reports size only — animating a height change is `useSmoothResize`. Keep your own ref with `useComposedRefs(myRef, measureRef)` from `lib/compose-refs`.

```tsx
const [measureRef, { width }] = useMeasure<HTMLDivElement>();
```

## useMediaQuery

`useMediaQuery(query)` (from `hooks/use-media-query`) — reactive boolean; the first client render reports the real match; callers of the same query share one `MediaQueryList`.

- Only when the answer changes behaviour — anything visual is a Tailwind breakpoint or container query. The server snapshot is `false`. Pass the full query: a bare `768` becomes the invalid `"768"` and reads `false` forever.

```tsx
const isCoarsePointer = useMediaQuery("(hover: none)");
```

## useIsMobile

`useIsMobile(breakpoint = 768)` (from `hooks/use-mobile`) — true below `breakpoint`, the first non-mobile width (`useIsMobile(768)` asks `(max-width: 767px)`).

- Behaviour only (`block/sidebar` swaps its rail for a sheet on it); the server snapshot is `false`. One listener per call — lift it to one owner instead of calling it per row.

```tsx
const isMobile = useIsMobile();
```

## useOverflowCount

`useOverflowCount<T>(itemCount, { reserve? })` (from `hooks/use-overflow-count`) — returns `{ containerRef, visibleCount, overflowCount }` for a "+N more" row fitted to rendered widths; `reserve` (px, default `0`) keeps room for the indicator.

- The row is `overflow: hidden` and non-wrapping; every item stays mounted, hidden past `visibleCount` with the `hidden` attribute; the indicator comes after the items. A fixed "first three" truncation is a slice, not this hook.

```tsx
const { containerRef, visibleCount, overflowCount } = useOverflowCount<HTMLDivElement>(tags.length, { reserve: 48 });
```

## useRangeSelect

`useRangeSelect(orderedIds, selectedIds)` (from `hooks/use-range-select`) — returns `{ select, reset }`; `select(id, event)` returns the next selection array.

- A plain click toggles and sets the anchor; shift-click selects anchor-to-click, and the anchor's direction wins. `orderedIds` is display order; call `reset()` when the list identity changes. `block/list` already wires it.

```tsx
const { select } = useRangeSelect(rows.map((row) => row.id), selectedIds);
<Row onClick={(event) => setSelectedIds(select(row.id, event))} />
```

## useRovingFocus

`useRovingFocus<T>({ orientation?, loop?, dir?, itemSelector?, enabled? })` (from `hooks/use-roving-focus`) — returns a callback ref for the group; defaults `orientation: "horizontal"`, `loop: true`, `dir: "ltr"`, `enabled: true`.

- Keeps one reachable item (the active one, else the first enabled); Home/End jump; disabled items skip. Focus moves, selection does not.

```tsx
const rovingRef = useRovingFocus<HTMLDivElement>();
<div role="tablist" ref={rovingRef}>{tabs}</div>
```

## useScrollOverflow

`useScrollOverflow<T>({ orientation?, threshold? })` (from `hooks/use-scroll-overflow`) — returns a callback ref that writes `data-overflow-y-start`/`-end` (or `-x-` for `orientation: "horizontal"`, RTL-aware) straight to the DOM, so scrolling never re-renders; `threshold` defaults to `1`px.

- Style the fade off the attribute (`[data-overflow-y-start]:before { … }`). Only for a bare scroll container — `element/scroll-area`'s `hasFadeEdges` already reads the same attributes.

```tsx
const scrollRef = useScrollOverflow<HTMLDivElement>();
<div ref={scrollRef} className="overflow-y-auto">{rows}</div>
```

## useSearchSource · createStaticSource

`useSearchSource(source, query)` (from `hooks/use-search-source`) — returns `{ items, status, error?, isBootstrap }` (`status`: `idle | loading | ready | error`). A source is `{ search(query), bootstrap?, debounceMs? }`; `createStaticSource(items, { filter?, bootstrap? })` wraps an in-memory list in the same shape.

- Only the newest request writes; a synchronous source never flashes `loading`. An empty query shows `bootstrap()` with `isBootstrap: true`, or nothing without one.

```tsx
const search = useSearchSource(source, query);
<Command searchValue={query} onSearchValueChange={setQuery} loading={search.status === "loading"} filter={null}>
  <Command.Results data={search.items} renderItem={(item) => <Command.Item value={item.value}>{item.label}</Command.Item>} />
</Command>
```

## useSmoothResize

`useSmoothResize<T>(enabled = true)` (from `hooks/use-smooth-resize`) — returns a callback ref that animates the element's real `height` (slow motion step × 1.2 on `--motion-ease-travel`), so content below reflows with it.

- First paint never animates; reduced motion and the still pace turn it off. For a page region prefer `<Block shouldSettle>`; settle the outermost region only. Plural or continuous motion wants a transform technique instead.

```tsx
const settleRef = useSmoothResize<HTMLDivElement>();
<div ref={settleRef}>{isExpanded ? <Details /> : <Summary />}</div>
```

## useTimeout

`useTimeout(callback, delayMs | null)` (from `hooks/use-timeout`) — arms when the delay is a number, `null` disarms; changing the delay restarts; unmount cleans up.

- Replaces a bare `setTimeout` resetting a transient flag. A second trigger while armed keeps the first deadline — flip through `null` to restart.

```tsx
useTimeout(() => setIsCopied(false), isCopied ? 2000 : null);
```

## useUppyState · createUppyUploadHandler

`createUppyUploadHandler(uppy)` and `useUppyState(uppy, selector)` (from `lib/uppy-adapter`) — bind a caller-owned `@uppy/core` instance to `compound/file-picker`'s `onUpload`. `@uppy/core` is an optional peer (the `file-picker` family); a picker that stages locally or uploads with plain `fetch` passes its own `onUpload` and skips this.

- The instance and its transport plugins are the caller's; the library ships no network code. A rejected `upload()` fails every file in the batch; progress needs a numeric `percentage` in Uppy's `upload-progress`.
- `useUppyState` is a `useSyncExternalStore` read — a selector that derives a new object must be module-scope or `useCallback`'d, or React loops.

```tsx
const uppy = useMemo(() => new Uppy().use(AwsS3, { endpoint: "/api/s3" }), []);
const upload = useMemo(() => createUppyUploadHandler(uppy), [uppy]);
<FilePicker value={files} onValueChange={setFiles} onUpload={upload}>…</FilePicker>
```
