Hooks
React hooks for the state and browser plumbing every app rewrites — disclosure, clipboard, storage, media queries, hotkeys, debouncing, and more. Eighteen of them, each with a worked example below. SSR-safe and tree-shakeable.
Import
import {
useDisclosure,
useClipboard,
useLocalStorage,
} from "@mikenotthepope/substrateui/hooks"A separate entry point
Hooks come from @mikenotthepope/substrateui/hooks, not the root. That keeps them out of the bundle of anyone importing only components, and it means a hook never drags a component in behind it.
useToggle
Cycles a list. With no arguments it is a boolean; with a tuple it walks the values in order and wraps. Pass a value to toggle to jump straight to it.
useCounter
A bounded integer. min and max clamp every path in — increment, decrement, and set — so a value out of range cannot get in through the back door.
usePrevious
The value from the render before this one. Derived from state adjusted during render rather than from a ref, so it stays pure and does not need an effect — which also means it is correct under Strict Mode's double render.
now —, before —
useDebouncedValue
A trailing copy of a value. The input stays controlled and responsive while whatever reads the debounced copy — a query, a validation, an expensive render — waits for the typing to stop.
Debounced (400ms): —
useInterval
A timer that keeps the latest callback without restarting. Passing a fresh closure each render is the usual way to end up with a setInterval that either sees stale state or resets on every render; this one does neither. The third argument pauses it.
useCountdown
Counts down to a deadline and hands back the parts, a locale-formatted string, and a finished flag. ready is false until mount, so a server render and the first client render agree — a countdown is different on the server by definition.
0m 0s left — the parts and a locale-formatted string.
useClipboard
Copies text and flips copied for as long as timeoutsays, which is the whole of the "Copied!" affordance. error is set when the browser refuses — the clipboard API needs a secure context and a user gesture, so this fails in more situations than you would expect.
useLocalStorage
Persisted state that survives a reload and stays in step across tabs and across every instance reading the same key. It reads nothing during a server render, so gate the displayed value on useMounted— otherwise the first client render disagrees with the server's and React discards it.
Stored under substrateui-docs-note: …
useMediaQuery
Any media query, tracked. Use it for behaviour, not for layout — layout belongs in CSS, where it works before JavaScript runs. The second argument is the value used before the first match, which matters on the server.
(min-width: 768px)(pointer: coarse)(prefers-reduced-motion: reduce)Resize the window to see the first one change.
useIsMobile
useMediaQuery pinned to the one breakpoint the suite itself uses — 768px, where Sidebar becomes a sheet. Worth using rather than restating the number, so your app switches where the components do.
useMounted
False on the server and on the first client render, true after. That is the point: both passes agree, and anything that can only be known in the browser waits for the second. Reach for it before typeof window !== "undefined", which does not agree between the two.
useClickOutside
Returns a ref; the handler fires on a pointer event outside that element. Bound to mousedown and touchstart rather than click, so a drag that starts inside and ends outside doesn't dismiss.
It is not a substitute for a dialog. Escape, focus trapping, and focus restoration are not here — use Dialog or Popover for anything modal, and this for the informal cases.
useHotkeys
Global bindings, as an array of pairs. mod resolves to ⌘ on Apple platforms and Ctrl elsewhere, which is the difference between one binding and two. ignoreInputs stops a shortcut firing while the user is typing in a field.
Anything you bind is a binding the browser or a screen reader may already own. Keep to chords a user expects, and give every shortcut a non-keyboard route to the same action.
Press Mod+K or Shift+?, then Esc to reset.
Last fired: —
useElementSize
The element's own size, from a ResizeObserver — so it reports a change the window never saw, such as a sibling collapsing or content reflowing. A window resize listener misses all of those.
0 × 0 — reported by ResizeObserver, not by a resize event.
useIntersection
Hands back the raw IntersectionObserverEntry, not just a boolean, so intersectionRatio and boundingClientRect are there when you need them. It is null until the observer first reports.
Scroll down…
…and back up.
useMergedRef
One element, several refs. Needed whenever you hold your own ref on an element another hook also wants — measuring an input you also focus, as below. An element takes one ref prop, so without this the second one silently wins.
useAnnouncer
Announces a message through a shared ARIA live region, for changes that have no visible text of their own — results loaded, an item removed, a background save finished. One region for the whole app, created on first use. Also exported as bare announce and clearAnnouncer functions for use outside a component.
Prefer marking up the changing content itself as a live region where you can. This is for the cases where there is nothing on screen to mark up. See Announcer for politeness levels and the timing rules.
0 loaded. Nothing changes on screen — turn a screen reader on, or inspect the shared live region, to hear it.
API Reference
Every hook, with its signature. All eighteen are SSR-safe — they read no browser API during render — and each is a separate module, so importing one does not pull in the rest.
| Prop | Type | Default | Description |
|---|---|---|---|
useDisclosure | (initial?) => [boolean, { open, close, toggle }] | — | Open/closed state for dialogs, drawers, and popovers, with onOpen/onClose callbacks. |
useToggle | (options?) => [value, toggle] | — | Cycle through a list of values (defaults to [false, true]); jump to a value by passing it. |
useClipboard | ({ timeout? }) => { copy, copied, reset, error } | — | Copy text and track a transient `copied` flag. |
useLocalStorage | (key, default) => [value, setValue] | — | SSR-safe persisted state, synced across tabs and instances. |
useMediaQuery | (query, initial?) => boolean | — | Track whether a CSS media query matches. |
useClickOutside | (handler, events?) => ref | — | Fire a handler on a pointer event outside the ref'd element. |
useHotkeys | (bindings, { ignoreInputs? }) => void | — | Global keyboard shortcuts; `mod` maps to ⌘/Ctrl by platform. |
useDebouncedValue | (value, delay?) => value | — | A debounced copy of a value for search inputs and derived work. |
useElementSize | () => [ref, { width, height }] | — | Observe an element's size with ResizeObserver. |
useIntersection | (options?) => [ref, entry] | — | Observe viewport intersection for lazy-load and reveal-on-scroll. |
useCounter | (initial?, { min?, max? }) => [count, handlers] | — | A bounded integer counter (increment/decrement/set/reset). |
usePrevious | (value) => value | undefined | — | The value from the previous render. |
useInterval | (callback, delay, active?) => void | — | Run a callback on an interval; latest callback without resetting the timer. |
useCountdown | (deadline, options?) => CountdownState | — | Count down to a deadline — units, a locale-formatted string, and a once-only onFinish. |
useMounted | () => boolean | — | True after client mount — gate browser-only UI. |
useMergedRef | (...refs) => refCallback | — | Merge several refs onto one element. |
useIsMobile | () => boolean | — | True below the mobile breakpoint (768px). |
useAnnouncer | () => { announce, clear } | — | Imperatively announce messages to screen readers via a shared ARIA live region (also exported as announce/clearAnnouncer). |