Skip to content
Hooks

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.

closed

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.

sm

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.

1clamped 0–10

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.

0ticks, 1s apart

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.

no match
(min-width: 768px)
no match
(pointer: coarse)
no match
(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.

not mobile
below 768px — the same breakpoint Sidebar switches to a sheet at

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.

server render
false on the server and on the first client pass, so both agree

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.

out of view
threshold 0.9 — scroll the box

Scroll down…

Watched element

…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.

measured by the second: 0px

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.

PropTypeDefaultDescription
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?) => booleanTrack whether a CSS media query matches.
useClickOutside(handler, events?) => refFire a handler on a pointer event outside the ref'd element.
useHotkeys(bindings, { ignoreInputs? }) => voidGlobal keyboard shortcuts; `mod` maps to ⌘/Ctrl by platform.
useDebouncedValue(value, delay?) => valueA 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 | undefinedThe value from the previous render.
useInterval(callback, delay, active?) => voidRun a callback on an interval; latest callback without resetting the timer.
useCountdown(deadline, options?) => CountdownStateCount down to a deadline — units, a locale-formatted string, and a once-only onFinish.
useMounted() => booleanTrue after client mount — gate browser-only UI.
useMergedRef(...refs) => refCallbackMerge several refs onto one element.
useIsMobile() => booleanTrue 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).