# Schema-driven UI

> Project the typed descriptor graph into UI — forms, tables, pickers, and reactive components, all bound to a descriptor with near-zero glue.



---

<!-- source: en/ui/overview.md -->
## Overview

_Project the typed descriptor graph into UI — forms, tables, pickers, and reactive components, all bound to a descriptor with near-zero glue._

You already own a typed schema on **both** sides of the wire: the entity DSL
(`*.entity.ts`) and the descriptor schemas (`defineQuery` / `defineMutation`
carry `effect/Schema` for input, output, and `target`). Schema-driven UI
**projects that into the frontend** — so a form, a table, or a picker is a
*binding to a descriptor* the framework already has, not hand-written glue.

Here's that idea live — a form bound to a mutation and a table bound to a query,
sharing one reactive backend. Add a row and it appears instantly, pushed from
the server (it's your own throwaway sandbox; it resets on refresh):

```tsx
<AutoForm api="app" mutation="todos.create" submitLabel="Add todo" />
<DataTable api="app" query="todos.list" />
```

## Why this isn't "yet another admin generator"

Auto-generated CRUD UIs fail because they generate against a *dead* REST/SQL
schema and hit a wall the moment you need anything custom. Voltro's version is
different on one axis: the generated UI binds to a **reactive, auto-optimistic
backend**. A `<DataTable>` is not a dead grid you refetch — it's a live
subscription that updates on any write, with optimistic patches derived from
the mutation descriptor's `target`, for free. The generation is the *delivery*;
the reactivity is the *value*.

## Binding symmetry — everything binds to a descriptor

| UI piece | binds to | gives you |
|---|---|---|
| a **form** | a **mutation** (`todos.create`) | fields from its input Schema, validation, submit, op-correct optimistic |
| a **field / picker** | a **query** (`users.search`) | live options, debounce, pagination |
| a **table** | a **query** (`todos.list`) | live rows, sort, pagination, row-action mutations |
| a **filter panel** | a **query**'s input | faceted controls + a live result count |
| a **detail view** | a **query** + relations | a live record + its eager-loaded relations |

There's no new data layer to learn. Customization is only ever choosing *how* a
binding renders — never re-plumbing the binding.

## The customization ladder (never lose the binding)

This is where 99% of schema→UI generators die: they nail the happy path, then
force a config DSL until it's worse than hand-written JSX — or you eject and
lose everything. The one rule that avoids both:

> Customization is a ladder of ever-smaller overrides, and on **no rung do you
> lose the binding** (schema validation + mutation submit + optimistic; for a
> picker, the live query). Eject from the *rendering* is never eject from the
> *binding*.

| Rung | How | You lose |
|---|---|---|
| 0 — schema annotation | `widget` / `label` on the field | nothing (zero form code) |
| 1 — per-field render-prop | `<Field name="x">{f => <MyWidget .../>}</Field>` | nothing — state/validation/submit stay |
| 2 — widget registry | map a widget-kind → component app-wide | nothing |
| 3 — layout override | arrange `<Field/>`s in your own JSX | nothing |
| eject — headless | `useFormBinding` / `useDataTable` + your JSX | only the auto-rendering — the binding stays |

`<AutoForm>` / `<DataTable>` are sugar over a **headless core**
(`useFormBinding` / `useDataTable`) — which is exactly why ejecting from the
rendering keeps everything underneath.

## Localizing the built-in strings

The components render a handful of user-facing labels — `Clear`, `Actions`,
`Search…`, the workflow status words (`Running` / `Succeeded` / …), presence's
"X is editing" sentence, and so on — with **English defaults**. Localize them
all at once by wrapping the app in `UiStringsProvider`, instead of threading a
prop through every component:

```tsx
import { UiStringsProvider } from '@voltro/web'

<UiStringsProvider
  strings={{
    filters: { clear: 'Zurücksetzen', resultCount: (n) => `${n} Ergebnisse` },
    dataTable: { actions: 'Aktionen' },
    workflow: { status: { running: 'Läuft' } },
    presence: { editingOne: (name, verb) => `${name} ist am ${verb}` },
  }}
>
  <App />
</UiStringsProvider>
```

Overrides **deep-merge** onto the defaults — supply only the sections and keys
you change; everything else stays English. Interpolated strings (counts, names)
are functions so a locale can reorder or pluralize. Providers nest (an inner one
merges onto the outer), and a per-component prop (e.g. `<AgentChat placeholder>`)
still wins over the context for a one-off tweak. `useUiStrings()` reads the
active strings; `defaultUiStrings` is the English baseline.

## Packaging

Three layers, all re-exported by `@voltro/web` so apps import everything from
the framework's web entry:

- **Headless bindings** (`useFormBinding`, `useDataTable`, `useQueryField`, …)
  → `@voltro/client` — pure logic, no kit dependency; works with any
  presentation (incl. an MUI/Mantine adapter).
- **The widget seam + components** (`<AutoForm>` / `<Field>` / `<DataTable>` /
  `<AsyncSelect>` + the registry + accessible HTML defaults) → `@voltro/ui`.
- **The styled kit** (Tailwind `shadcnWidgets` + tokens) → `@voltro/ui-shadcn`,
  which plugs into the seam via `<WidgetRegistryProvider widgets={shadcnWidgets}>`.

## In this section

- **[Forms & tables](./forms-and-tables)** — `<AutoForm>`, `<DataTable>`,
  query-bound pickers, the widget seam, skeletons, filters.
- **[Reactive components](./reactive-components)** — workflow progress, presence
  / multiplayer, AI chat — UI over the framework's durable + reactive backend.
- **[Client utilities](./client-utilities/use-can)** — `useCan`, `useDerived`,
  `useProvenance`, `useUndo`, `usePreview`, async validation, `<RecordView>`,
  the typed analytics catalog, the offline outbox, windowed subscriptions.



---

<!-- source: en/ui/forms-and-tables.md -->
## Forms & tables

_<AutoForm> binds to a mutation, <DataTable> to a query — fields and columns from the Schema, validation and live updates for free._

Import components from `@voltro/web`, headless hooks from `@voltro/client`.

## `<AutoForm>` — bind to a MUTATION

A form binds to **one mutation's** input `Schema`. This cleanly resolves
create-vs-update: they're *different mutations with different input schemas*, so
they're naturally different forms — no "CRUD mode" switch.

```tsx
import { AutoForm } from '@voltro/ui'

// fields from the mutation's input schema; client+server share the schema;
// submits via the mutation with op-correct optimistic (insert prepends, etc.)
<AutoForm api="app" mutation="todos.create" onSuccess={(t) => navigate(`/todos/${t.id}`)} />

// update: bind the update mutation + pass the existing row
<AutoForm api="app" mutation="todos.update" defaults={row} />
```

Here it is **live** — a real `<AutoForm>` bound to a throwaway demo API (your
own sandbox; data resets on refresh). Add a todo and watch it appear in the
table below, pushed from the server:

```tsx
<AutoForm api="docs" mutation="todos.create" submitLabel="Add todo" />
<DataTable api="docs" query="todos.list" />
```

A combined create-or-edit screen is a three-line wrapper:

```tsx
{row
  ? <AutoForm api="app" mutation="todos.update" defaults={row} />
  : <AutoForm api="app" mutation="todos.create" />}
```

### The customization ladder

- **Rung 0 — zero config.** Fields render from the schema: a `Schema.Literal`
  union → `select`; string → text; boolean → checkbox; `Date` → date; nested
  object → a `custom` placeholder asking for a render-prop.
- **Rung 1 — one custom widget** via a `<Field>` render-prop:
  ```tsx
  import { AutoForm, Field, AsyncSelect } from '@voltro/ui'

  <AutoForm api="app" mutation="todos.create">
    {() => (
      <>
        <Field name="title" />                                            {/* auto */}
        <Field name="assigneeId">
          {(w) => <AsyncSelect {...w} api="app" source="users.search" />} {/* live picker */}
        </Field>
      </>
    )}
  </AutoForm>
  ```
- **Rung 2 — swap a widget kind app-wide** via the registry:
  ```tsx
  import { WidgetRegistryProvider } from '@voltro/web'
  import { shadcnWidgets } from '@voltro/ui-shadcn'
  <WidgetRegistryProvider widgets={shadcnWidgets}>{/* AutoForms render styled */}</WidgetRegistryProvider>
  ```
- **Rung 3 — own the layout.** Pass children + arrange `<Field>`s (columns,
  sections, tabs); each still auto-renders.
- **Eject (headless).** `useFormBinding('app', 'todos.create')` returns
  `{ fields, values, errors, formError, isValid, pending, setValue, submit, reset }`
  for 100% custom JSX — the binding stays.

### Accessible by default

The built-in widgets render accessible HTML without any extra work: a `<label htmlFor>` tied to the control, `aria-required` on required fields (the visual `*` is `aria-hidden` — assistive tech learns "required" from the control, not from a spoken "star"), and, on error, `aria-invalid` + a `role="alert"` message associated via `aria-describedby`. A radio group ties its error to the whole `<fieldset>`.

Add **help text** by putting a `description` on the field's Schema — it renders as a hint and is associated to the control via `aria-describedby`, so a screen reader announces it with the label:

```ts
Schema.Struct({
  handle: Schema.String.annotations({ description: 'Shown on your public profile' }),
})
```

### Forms without JavaScript

On a server-rendered page, `<AutoForm>` works with JavaScript disabled — or not
yet loaded. It always renders `action="/form/<mutationTag>"` + `method="post"`,
so the browser has a native form-POST fallback; with JavaScript, `onSubmit`
intercepts as before (the RPC path, optimistic — unchanged).

The `/form/<mutationTag>` endpoint is mounted by the WEB listener on both boot
paths (`voltro dev` AND `voltro start`). It maps the posted `FormData` against
the SAME input schema the RPC path decodes:

- a checkbox present → `true`, absent → `false`
- `''` on a number/date field → the field is omitted (an optional field stays
  absent; a required one reports "missing" — never a silent `0`)
- arrays arrive as repeated keys (`getAll` semantics)
- a non-numeric string passes through RAW, so the decode fails honestly (never
  `NaN`)
- unknown keys are dropped

Validation runs through the same `validateFields` as the client-side
validation, so the error texts are identical. Then:

- **Success → `303 See Other`** (POST-redirect-GET): back to the submitting
  page, or to `redirectTo` (same-origin relative paths only; anything else is
  discarded). Reloading the target page never produces a second write.
- **Validation error → `422`**: the referer page is re-rendered in the same
  response, with the field errors and the entered values server-side in the
  same error UI (`role="alert"`, aria unchanged) — sent with
  `cache-control: no-store`, past the ISR cache. An RPC error AFTER valid
  input (a guard, the server) renders as a form-level error (`role="alert"`,
  `data-voltro-form-error`).
- **Multipart → `415`** ("file uploads need JavaScript").

Three props exist for this path:

- **`formKey?: string`** — distinguishes several forms on one page in the
  no-JS round-trip (default: the mutation tag); the 422 re-render fills only
  the submitted form.
- **`redirectTo?: string`** — where the success `303` goes (no-JS path only;
  with JavaScript, `onSuccess` applies). A same-origin relative path.
- **`action?: false`** — renders no `action` attribute, for pure
  static-hosting deploys (dist on a CDN without `voltro start`), where
  `/form/*` does not exist.

**On SSR pages, pass `schema` explicitly.** Descriptor resolution
(`descriptors[tag].input`) is a client-runtime feature; the SSR render sees an
empty descriptor map and would render zero fields without the `schema` prop.
Recommended source: import the schema from a shared browser-safe file — the
same one the mutation uses. One schema, no drift.

```tsx
export const renderMode = 'ssr' as const

<AutoForm api="app" mutation="notes.create" schema={noteInput} redirectTo="/thanks" />
```

Three limits, and this list is complete:

1. **The no-JS ERROR display** (the 422 re-render) works only on `ssr`/`isr`
   pages — the server cannot re-render a static page with request state
   (fallback: a minimal error page).
2. **Pure static-hosting deploys** (dist on a CDN, no `voltro start`) have no
   `/form/` endpoint — set `action={false}` there.
3. **File uploads stay JS-only** (multipart → `415`).

Security-wise, the endpoint forwards server-side to the api as `POST /rpc` —
auth middleware, guards and RPC interceptors run identically to the normal RPC
path; details, including the one boundary, in the
[security overview](/docs/security/overview).

Headless: `useFormBinding` now takes a `flash` option and returns a
`formError` field; `useFormFlash(formKey)` (`@voltro/web`) returns the flash —
on SSR from the request context, on the client from the
`#__voltro_form_flash__` JSON script. Both are identical, so hydration is
deterministic.

## `<DataTable>` — bind to a QUERY

Columns come from the query's output `Schema`; rows are a LIVE subscription
(update on any write, no refetch); headers sort client-side.

```tsx
import { DataTable } from '@voltro/web'

<DataTable
  api="app"
  query="todos.list"
  rowActions={(row) => <button onClick={() => del.mutate({ id: row.id })}>Delete</button>}
/>
```

**Live** — the same demo todos, with per-row actions wired to the toggle +
delete mutations. Open this page in a second tab and toggle or delete a row:
the other tab updates instantly, no refetch — that's the reactive query, not a
poll.

```tsx
<DataTable
  api="docs"
  query="todos.list"
  rowActions={(row) => (
    <>
      <button onClick={() => toggle.mutate({ id: row.id, done: !row.done })}>Done</button>
      <button onClick={() => del.mutate({ id: row.id })}>Delete</button>
    </>
  )}
/>
```

Headless eject: `useDataTable('app', 'todos.list', { initialSort, pageSize })`
→ `{ columns, rows, loading, error, sort, toggleSort, loadMore, hasMore }`.
`pageSize` opts into LIVE grow-the-window pagination (a "Load more" button; the
query applies `.limit(input.limit)`) — rows stay reactive as the window grows.
`renderCell` / `rowKey` / `emptyText` / `loadMoreText` cover the common overrides.

## Query-bound pickers

A picker binds to a query the way a form binds to a mutation. `<AsyncSelect>`
(or the headless `useQueryField`) drives a debounced, LIVE typeahead off a
source query — the option list updates reactively when the rows change:

```tsx
const picker = useQueryField('app', 'users.search', { labelField: 'name', valueField: 'id' })
// → { options, loading, term, search(term) }
```

Three cases need zero config because the schema says enough: an `enum` →
`select` (the options ARE the union); a `reference()` column → defaults to
`async-select` on the conventional `users.search`; a create-on-the-fly combobox
= a picker bound to a query (search) AND a mutation (`tags.create`).

## Skeletons — shape-matched loading, no CLS

While a bound query/mutation loads, render a placeholder shaped like the real
thing — derived from the same descriptor, so the swap causes zero layout shift.

```tsx
import { FormSkeleton, TableSkeleton } from '@voltro/web'
<FormSkeleton api="app" mutation="todos.create" />   {/* right field count */}
<TableSkeleton api="app" query="todos.list" rows={8} /> {/* right columns */}
```

## Filters — the read-side AutoForm

A query's INPUT Schema IS the filter spec. `<QueryFilters>` generates a control
per filterable input field; values map to the query input; the result + count
update LIVE (the query is a subscription).

```tsx
import { QueryFilters } from '@voltro/web'

<QueryFilters api="app" query="todos.list">
  {(f) => <DataTable api="app" query="todos.list" /* … */ />}
</QueryFilters>
```

Headless core: `useQueryFilters('app', 'todos.list')` →
`{ filters, values, setFilter, clear, rows, count, loading }`.

## The schema rule (load-bearing)

A descriptor's `input` MUST be `effect/Schema` — that's what the form, the wire,
and the capability map introspect. **Never put Zod (or another validator) in a
`*.mutation.ts` / `*.query.ts` descriptor.** Client-side EXTRA validation
(beyond the descriptor) may use any Standard-Schema validator.



---

<!-- source: en/ui/reactive-components.md -->
## Reactive components

_Drop-in UI for durable workflows, presence/multiplayer, and AI chat — bound to backend primitives the framework already has, never polling._

UI over the framework's durable + reactive backend. Each has a headless hook
(the escape hatch) + a styled component. Components from `@voltro/web`, hooks
from `@voltro/client`.

## Workflow-aware UI

No other framework has a UI vocabulary for durable execution. Bind to a workflow
run id; the timeline + wait-states push reactively (no status-poll endpoint to
wire). `useWorkflowRunState('app', runId)` is the headless aggregator over the
EXISTING run/steps/events subscriptions → `{ status, steps, currentStep,
waitingFor, error, traceId, cancel, resume, signal, update }`.

```tsx
import { WorkflowProgress, WorkflowStatusBadge, ApprovalControls } from '@voltro/web'
import { useWorkflowRunState } from '@voltro/client'

const run = useWorkflowRunState('app', runId)
<WorkflowProgress api="app" runId={runId} />       {/* live step timeline */}
<WorkflowStatusBadge api="app" runId={runId} />    {/* status + failing step + trace hint */}

// Approve/Reject a parked awaitSignal — renders ONLY while the run waits on it:
<ApprovalControls
  when={run.waitingFor?.name === 'approval'}
  onApprove={() => run.signal('approval', { approved: true })}
  onReject={() => run.signal('approval', { approved: false })}
/>
```

Signal/update payloads are schema-typed (declared on the workflow's `messages`
metadata) — don't pass stringly-typed blobs.

## Reactive multiplayer

`@voltro/plugin-presence` ships ephemeral "who's online" per channel. Combined
with the reactive engine, live-collaboration primitives become drop-ins —
prop-driven so the kit stays plugin-agnostic. Feed them the roster from
`usePresence` (`@voltro/plugin-presence/web`):

```tsx
import { PresenceAvatars, EditingIndicator } from '@voltro/web'
import { usePresence, useTyping } from '@voltro/plugin-presence/web'

const members = usePresence(`doc:${docId}`, { meta: { name, field: 'title' } })
<PresenceAvatars members={members} max={5} />
<EditingIndicator members={members} selfKey={myId} field="title" />

const typing = useTyping(`thread:${threadId}`, { selfKey: myId })  // { active, isTyping, start, stop }
```

Live cursors ride an ephemeral broadcast lane, NEVER the durable presence table
— don't write cursor positions at 60fps to a swept table.

## AI chat — `<AgentChat>`

A complete chat over an agent's synthesized `*.send` + `*.messages` — durable +
reactive, so reconnect/reload-surviving for free. Render from the persisted
`parts` (text / reasoning / tool / source / file) so reload === live.

```tsx
import { AgentChat, AgentStream, AppAgent } from '@voltro/web'
import { useAgentChat } from '@voltro/client'

const threadId = useRef(`thread_${crypto.randomUUID().replace(/-/g, '')}`).current
<AgentChat api="app" agent="support" threadId={threadId} />

// Headless — render your own bubbles from chat.messages[i].parts:
<AgentStream api="app" agent="support" threadId={threadId}>
  {(chat) => chat.messages.map((m) => /* … */)}
</AgentStream>
```

`<AppAgent>` is the end-user "do it for me" agent: `<AgentChat>` + a tools
disclosure (it acts AS the logged-in subject, so its ceiling is the subject's
permissions). Don't hand-wire `useResumableAgentStream` + a bubble list for a
standard chat — `<AgentChat>` is the supported path; reach for the resumable
hook directly only for a TRANSIENT (non-persisted) run over a `*.stream.ts`.

### `useAgentChat(apiName, agent, { threadId })`

The headless core `<AgentChat>` renders over. It opens no transport of its own —
it composes the agent's SYNTHESIZED pair: a `useSubscription` on
`<agent>.messages` (the persisted thread, including the live `streaming: true`
row) plus a `useAction` on `<agent>.send`. Because the feed is a reactive query,
reconnect- and reload-survival come for free.

```tsx
const chat = useAgentChat('app', 'support', { threadId })

await chat.send('Where is my order?')
await chat.send('Retry', { locale: 'en' })   // extra fields merge into the send input
chat.regenerate()                            // re-run the last user prompt
```

| Field | Meaning |
|---|---|
| `messages` | The thread, sorted by `order` then `stepOrder`. Each message has `id`, `role`, `content`, `streaming`, and the persisted `parts`. |
| `streaming` | `true` while any message row is still being written. |
| `loading` | `true` until the first snapshot arrives. |
| `error` | The subscription error, or the last send failure. |
| `send(prompt, extra?)` | Appends the user turn and drives the assistant turn. |
| `regenerate()` | Re-sends the last user prompt as a fresh turn; `undefined` if there is none. |
| `sending` | `true` while a `send` is in flight. |

`threadId` is required — mint one per chat (a `useRef`'d uuid) and keep it stable
across renders, since it is the subscription key for the whole thread. Render
from `message.parts` (text / reasoning / tool / source / file) rather than
`content` so a reloaded thread and a live one look identical.



---

<!-- source: en/ui/client-utilities/use-can.md -->
## useCan

_Gate UI on the same scope strings the server enforces — reactive permissions._

Gate UI on the SAME scope strings the server's `requireScope` checks — one
source of truth, no separate frontend permission list. Feed the subject's scopes
(from your session query) to `<PermissionProvider>`; `useCan` reads them.

```tsx
import { PermissionProvider, useCan } from '@voltro/client'

<PermissionProvider scopes={session.scopes}>…</PermissionProvider>
const canDelete = useCan('todos:delete')   // ADMIN bypasses; deny-by-default
```

UX only — the server still enforces. Pairs with `<AutoForm>` / `<DataTable>` to
auto-hide actions the subject can't perform.

The scope set itself, the `canCall` matcher, the OR variant (`useCanAny` /
`canCallAny`), and the per-RESOURCE gates (`useResourceCan` / `useResourceCans`)
are in [usePermissions](/docs/ui/client-utilities/use-permissions).



---

<!-- source: en/ui/client-utilities/use-derived.md -->
## useDerived

_A value computed from reactive sources, recomputed only when a source changes._

A value computed from reactive sources (subscription snapshots), recomputed only
when a source changes, same reference otherwise — the formula layer.

```tsx
const total = useDerived({ cart: cart ?? [], tax }, ({ cart, tax }) =>
  cart.reduce((s, i) => s + i.price, 0) * (1 + tax))
```

A derived value that needs the DB is a computed *query* (server), not this.



---

<!-- source: en/ui/client-utilities/use-preview.md -->
## usePreview

_Mutation dry-run — the {old,new} diff in a rolled-back transaction before commit._

"What will this change?" The app exposes a preview action that runs the real
handler in a transaction the framework **rolls back** (`previewInRollback` +
`buildPreview` from `@voltro/runtime`); `usePreview` returns the `{old,new}` diff
before commit. Actions (external I/O) aren't previewable — same boundary as undo.

```tsx
const p = usePreview('app', 'todos.update.preview')
const diff = await p.preview({ id, title })  // { rows, summary } — nothing committed
```



---

<!-- source: en/ui/client-utilities/use-async-validation.md -->
## useAsyncValidation

_DB-backed field validation as the user types — debounced, typed, over a query._

Async checks that need the DB (uniqueness, cross-row rules) run reactively as the
user types, debounced + typed, over a query — the same query gates submit.

```tsx
const email = useAsyncValidation('app', 'users.emailAvailable', value, {
  input: (v) => ({ email: v }),
  interpret: (r) => ({ valid: r.available, message: r.available ? undefined : 'Email taken' }),
})  // email.status: 'idle' | 'checking' | 'valid' | 'invalid'
```



---

<!-- source: en/ui/client-utilities/record-view.md -->
## RecordView

_Live read-only detail view for one record plus its eager-loaded relations._

The read counterpart to `<AutoForm>`: a live detail view for one record + its
eager-loaded relations (scalars → a definition list; arrays → nested tables).
[`useRecord`](/docs/ui/client-utilities/use-record) is the headless core.

```tsx
import { RecordView } from '@voltro/web'
<RecordView api="app" query="users.get" input={{ id }} />
```



---

<!-- source: en/ui/client-utilities/use-provenance.md -->
## useProvenance

_Last-write attribution (actor + traceId) for a (table, id, column)._

"Why is this value here?" Last-write attribution (audit actor + traceId) for a
`(table, id, column)`, over `/_voltro/inspect/provenance`. Out-of-band /
un-audited rows report no attribution (never a fabricated actor); computed
values are set-level. Available in dev.

```tsx
const prov = useProvenance('app', 'invoices', invoiceId, 'status')
// → { value, lastWrite: { actor, at, traceId }, attribution, found }
```



---

<!-- source: en/ui/client-utilities/use-undo.md -->
## useUndoLog

_Universal end-user undo + redo over mutations, server-persisted via synthesized inverses._

End-user Ctrl-Z over mutations, **server-backed**. When undo capture is on, the
framework records each mutation's change-set (the CDC `{old,new}` per row) into
`_voltro_undo_log` — inside the mutation's own transaction, so a rolled-back
write leaves no entry. Three built-in rpc procedures expose it, typed
end-to-end via codegen:

- `__voltro.undo.log` — a reactive, **subject-scoped** feed of your recent
  undoable actions (newest first).
- `__voltro.undo.apply` / `__voltro.undo.redo` — undo / redo one invocation by id.

`useUndoLog(api)` is the client controller:

```tsx
const undo = useUndoLog('app')
// → { entries, loading, canUndo, canRedo, undo, redo, undoLast, redoLast }

<button disabled={!undo.canUndo} onClick={() => undo.undoLast()}>Undo</button>
<button disabled={!undo.canRedo} onClick={() => undo.redoLast()}>Redo</button>
```

Undo applies the synthesized inverse as a **normal store write** — it traverses
the same tenant/RBAC guards, is reactive (the affected rows reappear / vanish
live, even across tabs), and is audited. It is **per-actor** (you can't undo
another subject's action), refuses on a concurrent-change **conflict** or past
an **action** boundary (an external side effect), and is idempotent. Because the
stack lives on the server, it survives a page reload.

## Enabling — `VOLTRO_UNDO`

Capture has a per-mutation cost (a read-before-write on update/delete + a
log-row insert), so it's **on outside production, off in production** by default;
set `VOLTRO_UNDO=on|off` to override (the same environment-aware default the
durable trace persistence uses). When capture is off, `_voltro_undo_log` isn't
created and nothing is recorded.

**The WIRE SURFACE is a separate question, and it reads only the explicit
declaration.** The three `__voltro.undo.*` procedures are generated into the
client's rpc group and bound on the server unless `VOLTRO_UNDO=off` — regardless
of `NODE_ENV`. The reason is that `rpcGroup.generated.ts` is written by `voltro
dev` and `voltro build` never regenerates it: an environment-derived answer baked
there is the developer machine's answer shipped to a production process that
binds none of it, and the failure appears only when someone presses undo after a
deploy.

So with capture off, the procedures exist and answer honestly rather than 404:
`useUndoLog` returns an empty list (nothing was captured) and apply/redo answer
`UndoNotFound`. Both boot paths log a line at startup saying so, so a
permanently empty undo list is not a mystery. `VOLTRO_UNDO=off` removes the
procedures from the server AND from the client bundle.

## Boundaries

Captures writes made through `ctx.store` (the same scope CDC covers) — raw
`unsafe()` SQL writes and the bulk helpers (`updateMany` / `deleteMany` /
`upsert`) aren't captured. Undo of an action-crossing invocation is refused
(the engine's "can't undo past an external effect" wall).



---

<!-- source: en/ui/client-utilities/analytics-catalog.md -->
## Analytics catalog

_Typed analytics — defineEvent + createAnalytics; track is compile-checked._

Declared, Schema-typed events: `track` is typed against the catalog (wrong name
/ payload = a compile error) + validated at runtime. One discoverable taxonomy.

```tsx
const analytics = createAnalytics([
  defineEvent('checkout.started', Schema.Struct({ plan: Schema.String })),
], sink)
analytics.track('checkout.started', { plan: 'pro' })   // typed + validated
```

Builds on `defineTracking` (the declarative per-component event map).



---

<!-- source: en/ui/client-utilities/use-outbox.md -->
## useOutbox

_Queue mutations offline, replay them in order on reconnect._

Queue mutations while offline, replay IN ORDER on reconnect — over the existing
optimistic layer. Replay stops at the first conflict to preserve causality. NOT
multi-device CRDT (single-user/device, honest about its limits).

```tsx
const outbox = useOutbox({ send })
// → { queue, enqueue, replay, online, pending, conflicts }
```



---

<!-- source: en/ui/client-utilities/use-windowed-subscription.md -->
## useWindowedSubscription

_Subscribe to only the visible viewport of a huge table — live and bounded._

For a huge table, subscribe to only the visible viewport (offset+limit from
scroll); the server pushes deltas only for in-window rows — live AND bounded.

```tsx
const w = useWindowedSubscription('app', 'rows.list', { rowHeight: 32, viewportHeight: 480, total })
<div style={{ height: 480, overflow: 'auto' }} onScroll={w.onScroll}>
  <div style={{ height: w.topSpacer }} />
  {w.rows.map(/* … */)}
  <div style={{ height: w.bottomSpacer }} />
</div>
```



---

<!-- source: en/ui/client-utilities/use-theme.md -->
## useTheme

_Read and set the app theme through the same cookie and class the pre-paint script owns._

With `theme: 'system'` the framework already emits a `<head>` script that reads
the `voltro:theme` cookie (falling back to `prefers-color-scheme`) and toggles
`.dark` on `<html>` before first paint. What was missing was the in-app
switcher — so apps layered a second theme system on top, and then two writers
raced on one `class` attribute: a flash on navigation, and a stored preference
that disagreed with the rendered class. `useTheme` is that switcher, reading and
writing exactly the cookie and class the pre-paint script already uses.

```tsx
import { useTheme } from '@voltro/web'

const { theme, resolvedTheme, setTheme } = useTheme()
<button onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}>
```

`theme` is the stored preference — `'light' | 'dark' | 'system'`, where
`'system'` means no explicit choice. `resolvedTheme` is what is actually applied
right now (`'light' | 'dark'`), resolving `'system'` against `matchMedia`, and
it stays live when the OS flips. `setTheme` persists the cookie and applies the
class immediately; `setTheme('system')` clears the cookie and falls back to the
OS.

Requires `theme: 'system'` in the web `app.config.ts` — that is what emits the
pre-paint script. Do not also install a third-party theme provider.



---

<!-- source: en/ui/client-utilities/use-connection-status.md -->
## useConnectionStatus

_One honest signal for whether the server is reachable — derived, never polled._

Connection health from the two signals the client genuinely has, so apps stop
hand-rolling a degraded-connection state machine in an auth provider.

```tsx
import { useConnectionStatus } from '@voltro/client'

const { status, reportSuccess } = useConnectionStatus('app')
{status !== 'connected' && <OfflineBanner status={status} />}
```

`status` is `'connected' | 'degraded' | 'offline'`. `offline` is browser-reported
(`navigator.onLine` plus its `online` / `offline` events) — reliable for "the
network is gone", and it wins over `degraded`. `degraded` means an rpc on that
api failed and nothing has succeeded since; it is real evidence, not a guess.
Coming back online clears the failure count, since failures counted during an
offline window *are* that window. `reportSuccess()` clears it eagerly from a
place that knows a call went through.

Also returned: `online`, `failureCount`, `lastFailureAt`. There is deliberately
no polling ping — the framework does not call the server just to colour an
indicator, so `connected` means "nothing has failed", not "just verified".



---

<!-- source: en/ui/client-utilities/use-debounced.md -->
## useDebounced

_Delay a value until typing stops — the one debounce the framework's own bindings use._

Every app that has a search box eventually writes the same six lines: a
`useState`, a `useEffect`, a `setTimeout`, and a cleanup that clears it. Get the
cleanup wrong and stale timers fire after unmount; get the dependency array
wrong and the timer never restarts. `useDebounced` is that snippet, written
once. `useAsyncValidation` and `useQueryField` are built on it, so a debounced
subscription in your code behaves exactly like the framework's own.

```tsx
import { useDebounced } from '@voltro/client'

const [term, setTerm] = useState('')
const debouncedTerm = useDebounced(term, 300)
const results = useSubscription('app', 'todos.search', { term: debouncedTerm })
```

The whole API is `useDebounced(value, ms = 300)`. There is no options object —
no leading edge, no `maxWait`, no `flush()` or `cancel()`. It is a trailing-edge
debounce and nothing else. If you need one of those, you need a different
primitive, not a flag on this one.

It is generic over the value, not limited to strings: `useDebounced(filters)`
over an object works the same way.

## Two behaviours worth knowing before you use it

**The first value is not delayed.** The internal state is seeded with `value` on
the very first render, so the initial value is returned synchronously — including
during SSR, where no timer ever fires. Only *changes* wait for the quiet window.
That is what you want (no empty first paint), but it means you cannot use the
"debounced value hasn't caught up yet" trick to detect the initial render.

Comparing the two is how you detect *pending* input, which is exactly what
`useAsyncValidation` does internally to show its `checking` state:

```tsx
const settling = term !== debouncedTerm
```

**The value is compared by identity.** `value` and `ms` are the effect's
dependencies, so a value that is a fresh object or array on every render
restarts the timer on every render. Pass primitives, or memoize:

```tsx
const filters = useMemo(() => ({ status, assignee }), [status, assignee])
const debouncedFilters = useDebounced(filters, 300)
```

Changing `ms` restarts the quiet window too, for the same reason — so derive the
delay from something stable rather than recomputing it inline.



---

<!-- source: en/ui/client-utilities/use-skeletons.md -->
## useFormSkeleton & useTableSkeleton

_Loading placeholders shaped like the real data — field and column descriptors from the same Schema the real UI uses._

A generic spinner tells the user nothing and reserves no space, so the layout
jumps the moment data arrives. The usual fix is to hand-write a placeholder with
"about four grey bars" — which then silently drifts from the form it is standing
in for. These two hooks remove that guesswork: the descriptor's Schema already
knows exactly how many fields the form will render and which columns the table
will have, *before* any data is fetched. Same source as `<AutoForm>` and
`<DataTable>`, so the placeholder and the real UI cannot disagree.

```tsx
import { useFormSkeleton, useTableSkeleton } from '@voltro/client'

const fields = useFormSkeleton('app', 'todos.create')   // mutation INPUT Schema
const columns = useTableSkeleton('app', 'todos.list')   // query OUTPUT Schema
```

Both take `(apiName, tag)` and return a `ReadonlyArray<FieldDescriptor>`. That
is the whole signature — there are no options.

## Reach for the component first

Most of the time you do not need these hooks at all — `@voltro/ui` ships
`<FormSkeleton>` and `<TableSkeleton>`, which call them for you:

```tsx
import { FormSkeleton, TableSkeleton } from '@voltro/ui'

{loading ? <FormSkeleton api="app" mutation="todos.create" /> : <AutoForm api="app" mutation="todos.create" />}
{loading ? <TableSkeleton api="app" query="todos.list" rows={5} /> : <DataTable api="app" query="todos.list" />}
```

Both accept a fallback count for the moment the descriptor is not resolvable yet
(`fallbackFields`, default 3; `fallbackColumns`, default 4), and both mark
themselves `aria-busy` + `aria-hidden` so a screen reader never announces the
placeholder bars.

The hooks are the headless layer underneath. Reach for them when the shipped
markup does not fit your design system and you want to render the placeholder
yourself:

```tsx
{loading
  ? fields.map((f) => <div key={f.name} className="h-10 animate-pulse rounded bg-muted" />)
  : <AutoForm api="app" mutation="todos.create" />}
```

Each `FieldDescriptor` carries `name`, `label` (the humanised property name),
`widget` (`'text' | 'textarea' | 'number' | 'checkbox' | 'switch' | 'select' |
'radio' | 'async-select' | 'multi-select' | 'date' | 'datetime' | 'daterange' |
…`), `required`, `nullable`, an optional `options` array for closed value sets,
and the resolved `jsonSchema` node. `widget` is what makes the placeholder
faithful — a `textarea` deserves a taller bar than a `checkbox`.

## Three things that will surprise you

**They know nothing about loading.** Neither hook subscribes to anything or
tracks a request; they read a Schema. The `loading` flag in the example above is
yours — from the `useSubscription` / `useMutation` whose data you are waiting
on. They are cheap and synchronous, and they work during SSR, which is why the
shape is correct on the very first paint.

**An unknown tag returns `[]`, silently.** A typo in the api name or the tag
produces an empty array, not an error — so a skeleton that renders nothing is
almost always a misspelled tag, not a Schema the hooks failed to read.

**Neither hook checks the descriptor's kind.** `useFormSkeleton` reads the
descriptor's `input`; `useTableSkeleton` reads its `output`. Pass a query tag to
`useFormSkeleton` and you get that query's input fields — useful for a filter
panel's skeleton, but it means nothing stops you from pairing the wrong hook
with the wrong tag. `useTableSkeleton` additionally returns `[]` unless the
output Schema is an array of objects.

The labels are humanised property names, not the Schema `title` annotation —
built-in refinements carry a type-name title (`nonEmptyString`) that would leak
into the UI. That matches what `<AutoForm>` renders, so the placeholder's label
widths line up with the real ones.



---

<!-- source: en/ui/client-utilities/use-capability-manifest.md -->
## useCapabilityManifest

_Read the api's capability manifest (procedures + tables + schemas) from the browser, without codegen._

What does this api actually expose? `useCapabilityManifest` answers that at
runtime: a **one-shot fetch** of `/_voltro/inspect/manifest` — the same inspect
surface [`useProvenance`](/docs/ui/client-utilities/use-provenance) rides. It is
a structural lookup, not a live subscription, so it does not re-fetch on data
changes.

```tsx
import { useCapabilityManifest } from '@voltro/client'

const { manifest, loading, error } = useCapabilityManifest('app')
```

The manifest carries:

| Field | Contents |
|---|---|
| `procedures` | Every procedure's `tag`, `kind`, `input`/`output` Schema, the `source` table a query reads, the `targets` (`{ table, op }`) a mutation writes, and the `guards` it declares. |
| `tables` | The user tables — `name`, `columns` (`type`, `nullable`, `refersTo`, `enum`, and the three exposure axes), `pkColumn`, `editable`, and whether the table is `reactive`. Framework `_voltro_*` tables are flagged `framework`. |
| `workflows` | The discovered workflow names. |
| `widgets` | The registered widget ids. |
| `scopes` | Every scope the installed plugins declare. Empty means "not declared" — never "no scopes exist". |
| `version` | The manifest format version. |

## Deriving an admin surface

`deriveEntityAdmins(manifest)` is the pure projection the [admin
template](/docs/templates/admin) is built on. It joins each user table to the
procedures that read and write it, so a back-office binds to tags that
**actually exist** instead of guessing them from a naming convention:

```tsx
import { useCapabilityManifest, deriveEntityAdmins } from '@voltro/client'

const { manifest } = useCapabilityManifest('app')
const entities = manifest ? deriveEntityAdmins(manifest) : []
// each: { table, columns, serverOnlyColumns, sensitiveColumns, reactive,
//         pkColumn?, editable,
//         list, create, update, delete }   // each an { tag?, guards? }
```

An action's `tag` is `undefined` when the app exposes no procedure for that
operation — render that affordance read-only rather than binding to a tag that
does not resolve.

### Why derive at runtime instead of generating an admin

A generated back-office snapshots the answer at codegen time. The moment you edit
`guards:` on a mutation the generated gate is stale — and stale in the silent
direction: it renders a control the server now refuses, or hides one that would
work. The manifest is served by the **running** app, so a derived surface cannot
drift from it. Customisation is not the tradeoff it looks like: the derived spec
is plain data, and the component that maps over it is your own template code.

## Gating on the access the api declares

Each action carries `guards` — the procedure's own `guards:` / `openAccess:`
declaration, the same data the server enforces. `useAccessDecision` turns it into
a decision against the scopes you fed
[`<PermissionProvider>`](/docs/ui/client-utilities/use-can):

```tsx
import { useAccessDecision, requiredScopes } from '@voltro/client'

const decision = useAccessDecision(entity.create.guards)  // 'allowed' | 'denied' | 'unknown'
{entity.create.tag && decision !== 'denied' ? <AutoForm … /> : null}
{decision === 'denied' ? <p>Requires {requiredScopes(entity.create.guards).join(', ')}</p> : null}
```

**The decision is three-valued, and `unknown` is the important one.** A guard that
carries a `resource` extractor is answered per **row** by the server, and a browser
holding only the subject's global scopes cannot pre-compute it. Both ways of
collapsing that gap are bugs:

| Collapse | What ships |
|---|---|
| `unknown` → `denied` | Every affordance disappears for callers whose authority is per-resource — the multi-tenant case, where subjects are minted with no global scopes. A total outage wearing a permission check's clothes. |
| `unknown` → `allowed` | A control that always errors. |

So show it and let the server answer: it is the authorization boundary, and it
replies with a typed `ScopeError`. `decideAccess(guards, scopes)` is the pure form
if you need it outside React.

`guards: undefined` means the procedure declared **neither** `guards:` nor
`openAccess:` — undecided, not open. That is refused outright under
`security.defaultDeny`, so it is reported as `unknown` rather than guessed.

## The three exposure axes in a derived UI

The manifest's columns carry all three schema markers, and they are **orthogonal** —
`deriveEntityAdmins` treats each one differently, and so must you:

| Marker | What it says | What the admin does |
|---|---|---|
| `.serverOnly()` | Never crosses **any** wire; the runtime refuses a mutation input that sets it | **Excluded** from `columns`; listed in `serverOnlyColumns` so the UI can say why it is absent |
| `.encrypted()` | Ciphertext **at rest** | **Kept.** Your procedures read it decrypted — hiding it is a category error |
| `.sensitive(class)` | Personal data; the **export**-masking axis | **Kept**, and listed in `sensitiveColumns` so a bulk export masks it |

`pkColumn` is the column row-keyed actions must target — do not hard-code `id`. When
a table has no single primary key, `editable` is `false` and no row can be addressed
for update or delete.

The manifest GET is bearer-gated wherever it runs — `/_voltro/inspect/*` is
fail-closed, so no configured `VOLTRO_INSPECT_TOKEN` means `401`, not "everyone".
Under `voltro dev` that is handled for you (the dev server mints a token and its
proxy attaches it server-side). An admin UI pointed at a deployed api has to
supply the token itself — a deployment concern, not something this hook
handles.



---

<!-- source: en/ui/client-utilities/use-permissions.md -->
## usePermissions

_The scope set useCan reads, plus the reactive per-resource gates scopes cannot express._

[`useCan`](/docs/ui/client-utilities/use-can) is only a matcher — it needs a
scope set to match against. `<PermissionProvider>` supplies it and
`usePermissions` reads it, so the whole app gates on one reactive source instead
of each component fetching the session again.

## PermissionProvider / usePermissions

```tsx
import { PermissionProvider, usePermissions, canCall, ADMIN_SCOPE } from '@voltro/client'

<PermissionProvider scopes={session.scopes}>…</PermissionProvider>

const { scopes } = usePermissions()   // ReadonlyArray<string>
```

`<PermissionProvider>` takes exactly `scopes` and `children`. Mount it once high
in the tree, fed by your session subscription — scopes are ordinary reactive
data, so a role change re-renders every gate.

With no provider above it, `usePermissions()` returns `{ scopes: [] }`, which
makes `useCan` deny. That is the deliberate default: a missing provider hides
affordances rather than revealing them.

`canCall(subjectScopes, required)` is the same matcher as a plain function, for
loaders and route guards that are not components. `required` as an array means
ALL of them (AND); `ADMIN_SCOPE` (`'admin:full'`) satisfies anything; an empty
requirement always passes.

## useCanAny / canCallAny

`useCan` with an array demands ALL of the scopes. `useCanAny` is the OR variant —
true when the subject holds AT LEAST ONE. Use it for "this section is visible to
editors OR reviewers" affordances.

```tsx
import { useCanAny, canCallAny } from '@voltro/client'

const canReview = useCanAny(['notes:edit', 'notes:review'])
```

`canCallAny(subjectScopes, required)` is the same OR check as a plain function,
matching `canCall`. `ADMIN_SCOPE` satisfies it, and an empty requirement passes —
nothing is being demanded.

`@voltro/client` is the one place these live. Scopes are a framework concept —
`@voltro/protocol` owns `ScopeError`, `guards: [{ scope }]` and `ctx.access` —
and [rbac](/docs/plugins/rbac) is only ONE way to produce them; an app can
register its own `setResourceScopeResolver` over its own tables instead. These
hooks CONSUME scopes, so they must not drag in a plugin that PRODUCES them.

## useResourceCan / useResourceCans

Scopes answer "may this subject delete todos". They cannot answer "may this
subject delete **todo 42**" — that depends on relation tuples only the server
holds. These two hooks ask the server, reactively.

```tsx
import { useResourceCan, useResourceCans } from '@voltro/client'

const { allowed, pending } = useResourceCan('app', 'docs.can', {
  action: 'write', resourceType: 'doc', resourceId: id,
})

const { allowedIds } = useResourceCans('app', 'docs.canMany', {
  action: 'delete', resourceType: 'doc', resourceIds,
})
<DataTable … rowActions={(r) => allowedIds.has(r.id) ? <DeleteBtn id={r.id} /> : null} />
```

Both take `(apiName, rpcTag, input, options?)`, where `options` is the ordinary
[`SubscriptionOptions`](/docs/reference/hooks-data) (`skip`, `fallback`) — they are thin
projections over `useSubscription`. You bind them to a server query whose source
is the ReBAC tuple table, returning `{ allowed }` or `{ allowedIds }`; because
that source is reactive, a revoke re-runs `can()` server-side and the button
disables with no refetch.

`useResourceCan` returns `{ allowed: boolean, pending: boolean }` —
`allowed` is a real boolean that starts `false` and stays `false` while
`pending`, never `undefined`. `useResourceCans` returns
`{ allowedIds: ReadonlySet<string>, pending: boolean }`; an id absent from the
set is denied. Both fail closed.

All of this hides affordances. The server still enforces every call through the
same `can()` engine — see
[Authorization](/docs/authentication/authorization).



---

<!-- source: en/ui/client-utilities/use-on-rpc-error.md -->
## useOnRpcError

_One place to react to rpc failures that are nobody's local problem — auth loss, telemetry, toasts._

Some failures do not belong to the component that triggered them. An
`Unauthenticated` error means the session is gone, whatever screen you happen to
be on. Rather than repeat that check in every `useMutation` call site, each api's
runtime carries an error bus; this hook subscribes to it with React lifecycle.

```tsx
import { useOnRpcError } from '@voltro/client'
import { errorTag } from '@voltro/protocol'

useOnRpcError('app', useCallback((e) => {
  if (errorTag(e.error) === 'Unauthenticated') redirectToSignIn()
}, []))
```

The event is `{ source, tag, error, traceId? }`. `source` is
`'mutation' | 'action' | 'subscription'`, so both unary calls and stream
failures arrive here. `error` is the raw value — a `Schema.TaggedError`, a plain
`Error`, or anything else that was thrown; `errorTag(err)` reads `_tag` off
tagged errors and returns `undefined` otherwise. `traceId` is the same id the
server logged, so a handler can point at `voltro logs --trace <id>`.

Two things to know. The hook re-subscribes whenever the listener **reference**
changes, so define it with `useCallback` or at module scope unless you want that.
And the bus is per api runtime — an app talking to several apis subscribes once
per api name.

This is the read side of failures that already happened on the wire. To push a
client-side error the server never saw, call `reportClientError(error, context)`
instead. Note also that a subscription failure *after* data arrived reaches only
this bus, never the hook's `error` field — see
[Queries](/docs/data/queries).



---

<!-- source: en/ui/client-utilities/use-refresh-subscriptions.md -->
## useRefreshSubscriptions

_Re-issue every live subscription after the connection's subject changes — no reload, no reconnect._

When a user signs in, the server rebinds the subject on the *existing*
connection. Every open subscription is now snapshotting against the wrong
subject's tenant scope. Reloading the page would fix it and lose everything else;
this hook is the surgical version.

```tsx
import { useRefreshSubscriptions } from '@voltro/client'

const refresh = useRefreshSubscriptions('app')
await signIn(...)
refresh()
```

It returns a stable zero-argument function. Calling it interrupts each active
subscription's fiber and re-forks it over the **same** WebSocket — no reconnect.
Server-side each one is a fresh subscription, so it flows through the auth
middleware again and resolves the new subject.

Optimistic patches survive the refresh: they are tied to mutation lifecycles, not
subscription lifecycles, and the new snapshots land underneath them.

The **data does not** — each entry's rows are cleared and the components go back
to `loading` until their new snapshot arrives. That is deliberate: the whole
reason to call this is that the identity changed, and the new subject may be
entitled to strictly less than the old one. It is the same boundary that stops a
[reconnect](/docs/data/subscriptions#reconnect) from seeding across a
`useReconnect()` — a dropped connection keeps your screen, a change of identity
clears it.

This is not a cache-invalidation tool. Subscriptions are already live, so a
normal write needs no refresh — reach for this only when the *connection's
identity* changed underneath them.



---

<!-- source: en/ui/client-utilities/use-tracking.md -->
## useTracking

_A declarative event map next to a component instead of track() calls sprinkled through handlers._

Imperative `track()` calls rot: they live inside handlers, get forgotten on the
new code path, and vanish in a refactor. `useTracking` moves the instrumentation
into one declarative map beside the component, so what a component reports is
readable in a single place.

```tsx
import { defineTracking, useTracking } from '@voltro/client'

interface CheckoutButtonProps {
  readonly plan: 'free' | 'pro'
  readonly onClick: () => void
}

const spec = defineTracking<CheckoutButtonProps>('CheckoutButton', {
  onMount: 'checkout.viewed',
  onClick: (props) => ({ event: 'checkout.started', plan: props.plan }),
})

const CheckoutButton = (props: CheckoutButtonProps) => {
  const tracked = useTracking(spec, props, sink)
  return <button {...tracked}>Checkout</button>
}
```
**Name the props.** The type parameter is what lets a payload builder read
`props.plan` instead of indexing into `unknown` and casting — and a cast is the
last thing that belongs in the one file that states what leaves the browser. It
defaults to an untyped bag, so a loose catalogue still compiles; `useTracking`
then returns the props type, which is what makes `{...tracked}` typecheck at all.

`useTracking(spec, props, sink)` returns a **copy of your props** with every
callback the map names wrapped, so invoking `onClick` fires its event and then
calls your original handler. Spread the returned props; nothing is mutated.

In the map, `onMount` and `onUnmount` are lifecycle — they fire from an effect,
once per mount, and are never wrapped as props. Every other key is the name of a
callback prop to wrap. An entry is either a bare event name or a function of the
props returning `{ event, ...payload }`.

The `sink` is yours to provide: `(event: TrackingEvent) => void`. The kernel is
transport-agnostic — it does not know where events go, which is also what makes
it testable with a recorder.

`resolveTrackingEvent(entry, props)` and `wrapTrackedCallbacks(spec, props, sink)`
are exported as pure functions if you need the behaviour outside a component.

For a typed, validated event taxonomy on top of the same sink, see the
[Analytics catalog](/docs/ui/client-utilities/analytics-catalog).



---

<!-- source: en/ui/client-utilities/use-record.md -->
## useRecord

_One live record from a "get" query, normalized — the headless core of RecordView._

A detail view wants *one* record, but a "get" query may return a row array or a
single object depending on how it was written. `useRecord` subscribes and
normalizes that away, so the component never branches on the shape.

```tsx
import { useRecord } from '@voltro/client'

const { record, loading, error } = useRecord('app', 'users.get', { id })
```

An array result yields its **first** row; an object result is the record itself;
anything else is `undefined`. `loading` is true exactly while no snapshot has
arrived, and `error` is the underlying subscription's cold-start error.

It is a thin projection over `useSubscription` — same live semantics, so the
detail view updates on any write with no refetch — with the single difference
that it takes no options argument, only `(apiName, queryTag, input?)`. Eager
loaded relations arrive as nested arrays on the record.

Reach for it when you want the data but not the markup.
[`<RecordView>`](/docs/ui/client-utilities/record-view) is this hook plus a
rendered definition list and sub-tables.
