Small hooks that carry a decision the apps kept re-making by hand: how a key shortcut is registered, when a fast-changing value settles, how a stored preference survives reload without a flash, how a link opens a modal instead of a page, and why a disabled button is disabled. None renders anything; each is a contract about behaviour that a screen would otherwise get slightly wrong.
The canon behind it. method/design-language/02-buttons-and-actions.md §A
(a button that cannot act explains itself, never silently disabled) for
useDisabledReason; method/design-language/10-overlays.md §A1 (peek versus
modal versus page) for useNavHijack.
When to reach for it#
useKeyPresswhen a page has a keyboard shortcut: Escape to close, arrows to move, a modifier combo to page.useDebouncewhen a search box or live filter should wait until typing stops.useLocalStorageanduseSessionStoragewhen a preference must survive reload and every component using the key must agree, on this page or in another tab.useActionwhen a click runs something that takes time or can fail: save, delete, re-run. It shows pending, then a success or error toast, and offers a retry.ThemeToggleandthemeBootScriptfor the light and dark switch.useNavHijackwhen a link rendered by content you do not control should open an overlay rather than navigate.useDisabledReasonwhen an action has more than one reason it might be unavailable and the reader must see the one that applies.
Do not reach for useLocalStorage for server state; it is for the reader's
own preferences. Do not reach for useNavHijack to build routing; it
intercepts, it does not route.
Contract#
useKeyPress(filter, callback, options?):filteris one key name (KeyboardEvent.key), a list, or a predicate over the raw event for modifier combos. The callback is held in a ref, so an inline function never re-registers the listener.useDebounce<T>(value, delay = 300): T: followsvalueand settles after it stops changing fordelayms; a pending change is cancelled on further change or unmount.useLocalStorage<T, K extends string>(key, initial)anduseSessionStorage: auseStatemirrored to storage as JSON (JSON.stringifyon write,JSON.parseon read, so a string is stored with its quotes and anything not JSON-serialisable is lost; a corrupt entry falls back to the initial value), built onuseSyncExternalStoreso the stored value is read synchronously on the client (no flash of the default after hydration) and every subscriber re-renders on a write, same tab or another. Returns[value, set, clear](UseStorageReturn). The key type is the caller's: pass an app-wide union asKso a typo fails to compile.useLocalStorage(key, initial, { encoding: "raw" })stores a string exactly as given (dark, not"dark"), for a key that older code or another tool wrote without quotes. It keeps cross-tab sync and the server snapshot, so an app no longer needs its own storage helper to read such a key. The initial value and the value must be strings; the default stays JSON.useSessionStoragetakes the same option.useAction(fn, options?): wraps an async function and returns{ run, pending, error }.run(...args)never throws; it resolves{ ok: true, value }or{ ok: false, error }.startshows a spinner toast,success(a string, or a function of the result) replaces it with a success toast, anderror(a string or function; the default is the error's own message,falsefor silence) replaces it with an error toast. The toasts share one id, so they replace each other instead of stacking.retry: trueadds a Retry button to the error toast that runs the call again with the same arguments.onSuccess,onErrorandonSettledare for the caller's own follow-up, such as reloading a list. A secondrunwhile one is in flight is ignored and gets the first run's outcome (ignoreWhilePending: falseto allow it).pendingfeeds aButton'sloading. Needs<Toasts />mounted once near the root.themeBootScript({ storageKey, default?, legacy? })returns the inline script that sets the reader's theme on<html>before the first paint, andThemeToggle(storageKey,default?,variant?) is the sun and moon button that flips it. Together they implement the canon (method/design-language/01-foundations.md§A6): light by default, dark a per-reader choice stored by name (versable-lightorversable-dark), the OS setting ignored, no flash on load.legacymaps an older stored value ({ dark: "versable-dark" }) so existing readers keep their choice, and a quoted value written byuseLocalStorageis accepted. Put the script string in a<script>in the head, ahead of React, and renderThemeToggleanywhere; it renders hidden until mounted because the server cannot know the reader's theme.useNavHijack(...rules): eachNavHijackRuleis a URL match (string prefix or regex), the handler to run instead of navigating, and a switch to disable it. One capturing document listener catches links rendered by anything.useDisabledReason(checks): a priority-ordered list of[condition, reason]; falsy entries are allowed so checks can be spliced in conditionally. The first failing check wins, which makes the ordering the message: loading first, validation next, plan limits last.
URL state: useQSync#
useQSync(param, options?) lives in @versable-git/qsync/react-router, not in
this package, so an app without react-router never loads it. It returns
[value, setValue] for one URL query param; setValue(null) clears it.
- Reload guarantee. The value is read from the router's URL on every render and is never held in component state. A reload, a pasted link and the back button all restore it, and the first render already has the right value (no flash of the default). Do not copy it into
useState. - Usage. Use it for a selected row, an open tab or an open preview, anything a person should be able to link to. Defaults are
replaceand no scroll reset, so toggling does not grow history; pass{ replace: false }when each state should be a back-button step. Two hooks writing in the same tick land as one navigation and never overwrite each other. Params the hook does not own are left alone. Values are strings; encode anything richer yourself. - Use
useLocalStorageinstead for a preference that is the reader's own and should not appear in a shared link. - Testing. Mount the component on
createMemoryRouterwithinitialEntries: ["/?tab=info"], assert the first render showsinfo, drive a write insideact()and readrouter.state.location.search. To test a reload, unmount, create a fresh router seeded with that search string and assert the same value. The reference tests arepackages/qsync/src/use-qsync.react-router.test.tsx.
import { useQSync } from "@versable-git/qsync/react-router";const [tab, setTab] = useQSync("tab");<Tabs value={tab ?? "info"} onChange={setTab} items={items} />Sanctioned combinations#
| Combination | Produces | Where used | Why |
|---|---|---|---|
useDisabledReason feeding a Button's disabled and tooltip | A button that explains itself on hover | the playground /components/hooks gallery, the Button contract's disabled rule | Canon 02 §A: a button that cannot act is hidden or explains itself, never silently disabled |
useDebounce(input, 300) feeding a DataTable search | A search that filters when typing stops | the list recipe (method/app-patterns/10-recipe-browsable-list.md) | One filter pass per pause, not per keystroke |
useKeyPress("Escape", close) in a Modal or SidePanel caller | The overlay closes on Escape | the overlay components carry this themselves; a caller adds it only for a custom overlay | One listener, held in a ref, never re-registered |
useAction(api.save, { start: "Saving", success: "Saved", retry: true }) feeding a Button's loading | A click that shows pending, then a result, and offers a retry on failure | the playground /components/hooks gallery | Canon law "Every action answers": a click shows pending, then a result or an error |
themeBootScript({ storageKey }) in the head and ThemeToggle with the same storageKey | A theme switch with no flash on load | the playground /components/hooks gallery | The script and the toggle must agree on the key and the default or the first paint disagrees with the button |
useLocalStorage<Prefs, AppStorageKey>("prefs", DEFAULTS) | A preference that survives reload without a hydration flash | the playground's theme and rail state | useSyncExternalStore reads the stored value before first paint |
Banned combinations#
useDisabledReasonwith the checks in an arbitrary order. The first failing check is the message; a plan-limit reason shown while the data is still loading tells the reader the wrong thing.useLocalStoragefor data the server owns (a job's status, a record). It is a preference store; server state goes through the app's data layer.useNavHijackas a router. A rule intercepts one link shape to open an overlay; building navigation out of rules recreates a router nobody can read.useActionaround a call whose failure the screen already renders inline without passingerror: false. The reader sees the failure twice, which breaks "one signal, said once".- A
ThemeToggleand athemeBootScriptwith different keys or defaults. The page paints one theme and the button believes another. - A hand-rolled
try,catchandpushAlertblock around a button's action. That isuseAction. - A fresh
useKeyPresslistener per key inside a list row. One listener at the list level with a predicate; per-row listeners multiply and fight.
Before you adopt this#
Five questions to answer before reaching for a hook here.
- Does the shell, a parent layout, or a global provider already render this?
ModalandSidePanelalready close on Escape;AppShellalready persists the rail state. - Does this app already ship a local implementation of the same thing? Speedway's early hooks folder had its own debounce and keypress; the kit's are the promoted versions (
packages/ui/CHANGELOG.mdsays which release). - Does this app's kit pin reach the version this hook landed in? Check the changelog against the pin.
useAction,ThemeToggle,themeBootScriptand the raw storage option arrive with the next kit bump after 0.5.0; an app adopts them on upgrade by deleting its own copies (a local pending-and-toast helper, its theme toggle and inline boot script, a bare-string storage wrapper). - Does the component derive its own accessible name and keyboard path, or must the call site supply them? Hooks render nothing; the control they drive names itself.
useDisabledReason's reason must reach atooltipor the explanation is lost. - Which canon §A rules bind this surface, and which does the composition break? Canon 02 §A on disabled controls; canon 10 §A1 on which overlay a hijacked link opens.
Travels with#
Button, whosedisabledandtooltipare whatuseDisabledReasonfeeds.DataTableand its search, the usualuseDebouncecaller.ModalandSidePanel, the overlays auseNavHijackhandler opens.AppShell, which uses the storage hooks for its own rail state.
Snippet#
import { Button, useDebounce, useDisabledReason } from "@versable-git/ui";const query = useDebounce(input, 300);const guard = useDisabledReason([ [isLoading, "File details are loading"], [!template, "No template selected"], [overLimit, "This plan allows 10 runs a day"],]);<Button content="Run" {...guard.buttonProps} onClick={run} />// guard.buttonProps is { bricked, tooltip }: the button stays interactive and explains itself; guard.disabled and guard.reason are there when you need the raw pair