Agent docs

Interaction hooks

The interaction hooks the apps lean on, keyboard shortcuts, debounced input, hydration-safe storage, link interception, disabled reasons and running an action with toasts, plus the theme switch, and the one rule each carries.

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#

  • useKeyPress when a page has a keyboard shortcut: Escape to close, arrows to move, a modifier combo to page.
  • useDebounce when a search box or live filter should wait until typing stops.
  • useLocalStorage and useSessionStorage when a preference must survive reload and every component using the key must agree, on this page or in another tab.
  • useAction when 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.
  • ThemeToggle and themeBootScript for the light and dark switch.
  • useNavHijack when a link rendered by content you do not control should open an overlay rather than navigate.
  • useDisabledReason when 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?): filter is 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: follows value and settles after it stops changing for delay ms; a pending change is cancelled on further change or unmount.
  • useLocalStorage<T, K extends string>(key, initial) and useSessionStorage: a useState mirrored to storage as JSON (JSON.stringify on write, JSON.parse on 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 on useSyncExternalStore so 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 as K so 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. useSessionStorage takes 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 }. start shows a spinner toast, success (a string, or a function of the result) replaces it with a success toast, and error (a string or function; the default is the error's own message, false for silence) replaces it with an error toast. The toasts share one id, so they replace each other instead of stacking. retry: true adds a Retry button to the error toast that runs the call again with the same arguments. onSuccess, onError and onSettled are for the caller's own follow-up, such as reloading a list. A second run while one is in flight is ignored and gets the first run's outcome (ignoreWhilePending: false to allow it). pending feeds a Button's loading. 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, and ThemeToggle (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-light or versable-dark), the OS setting ignored, no flash on load. legacy maps an older stored value ({ dark: "versable-dark" }) so existing readers keep their choice, and a quoted value written by useLocalStorage is accepted. Put the script string in a <script> in the head, ahead of React, and render ThemeToggle anywhere; it renders hidden until mounted because the server cannot know the reader's theme.
  • useNavHijack(...rules): each NavHijackRule is 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 replace and 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 useLocalStorage instead for a preference that is the reader's own and should not appear in a shared link.
  • Testing. Mount the component on createMemoryRouter with initialEntries: ["/?tab=info"], assert the first render shows info, drive a write inside act() and read router.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 are packages/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#

CombinationProducesWhere usedWhy
useDisabledReason feeding a Button's disabled and tooltipA button that explains itself on hoverthe playground /components/hooks gallery, the Button contract's disabled ruleCanon 02 §A: a button that cannot act is hidden or explains itself, never silently disabled
useDebounce(input, 300) feeding a DataTable searchA search that filters when typing stopsthe 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 callerThe overlay closes on Escapethe overlay components carry this themselves; a caller adds it only for a custom overlayOne listener, held in a ref, never re-registered
useAction(api.save, { start: "Saving", success: "Saved", retry: true }) feeding a Button's loadingA click that shows pending, then a result, and offers a retry on failurethe playground /components/hooks galleryCanon law "Every action answers": a click shows pending, then a result or an error
themeBootScript({ storageKey }) in the head and ThemeToggle with the same storageKeyA theme switch with no flash on loadthe playground /components/hooks galleryThe 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 flashthe playground's theme and rail stateuseSyncExternalStore reads the stored value before first paint

Banned combinations#

  • useDisabledReason with 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.
  • useLocalStorage for data the server owns (a job's status, a record). It is a preference store; server state goes through the app's data layer.
  • useNavHijack as a router. A rule intercepts one link shape to open an overlay; building navigation out of rules recreates a router nobody can read.
  • useAction around a call whose failure the screen already renders inline without passing error: false. The reader sees the failure twice, which breaks "one signal, said once".
  • A ThemeToggle and a themeBootScript with different keys or defaults. The page paints one theme and the button believes another.
  • A hand-rolled try, catch and pushAlert block around a button's action. That is useAction.
  • A fresh useKeyPress listener 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.

  1. Does the shell, a parent layout, or a global provider already render this? Modal and SidePanel already close on Escape; AppShell already persists the rail state.
  2. 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.md says which release).
  3. Does this app's kit pin reach the version this hook landed in? Check the changelog against the pin. useAction, ThemeToggle, themeBootScript and 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).
  4. 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 a tooltip or the explanation is lost.
  5. 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, whose disabled and tooltip are what useDisabledReason feeds.
  • DataTable and its search, the usual useDebounce caller.
  • Modal and SidePanel, the overlays a useNavHijack handler 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
@versable-git/ui · reference, canon, and method, read in place