Data hooks
useSubscription, useMutation, useAction, useWorkflow, workflow status hooks, useAgentStream, and useAgent.
Data hooks from @voltro/client are keyed by api name plus RPC tag. The tags come from descriptors discovered in the API app.
createHooks(apiName) — the typed hook binding
createHooks turns the api's generated procedure map into hooks whose RPC tag is a literal union and whose input and output types are inferred. It is the recommended way to call an api from app code.
Codegen emits AppProcedures into the api's rpcGroup.generated.ts — a type-level map of every tag (yours and every plugin's) to the descriptor behind it. Bind it once, at module scope, next to the rest of your api glue:
// src/lib/api.ts — one file, one line, once per app
import { createHooks } from '@voltro/client'
import type { AppProcedures } from '@app/api/rpcGroup'
export const { useSubscription, useMutation, useAction } = createHooks<AppProcedures>('app')'app' is the key this web app gave the api in app.config.ts → apis. It is now spelled exactly once per app instead of once per call site:
import { useSubscription, useMutation } from '../lib/api'
const { data } = useSubscription('notes.list') // ReadonlyArray<Note>, inferred
const create = useMutation('notes.create') // input + output inferredWhat the compiler catches that it could not before:
| Mistake | Before | Now |
|---|---|---|
Typo in the tag ('notes.lst') |
Runtime dev-console error | Compile error |
Wrong hook for the kind (a mutation passed to useSubscription) |
Runtime console.error |
Compile error |
| Missing a required input field | Request sent with undefined |
Compile error |
| Wrongly-shaped input | Server-side decode failure | Compile error |
| Result type annotation disagreeing with the server | Never detected | Impossible — there is no annotation |
The result keeps every narrowing rule of the untyped hook: loading still discriminates data, fallback / initialSnapshot still remove the branch, and only a dynamic skip adds the idle state.
Row types include the auto-optimistic marker the client adds, so row.optimistic type-checks on a live list without a hand-written row mirror.
Destructure the result — do not export the object and call api.useSubscription(...). react-hooks/rules-of-hooks only recognises a member call as a hook when the object is PascalCase, so a lowercase namespace silently switches off rules-of-hooks and exhaustive-deps at every call site.
import type is erased at build time, so the binding adds nothing to the browser bundle.
rpcGroup.generated.ts is written by codegen, so a tree that has never booted does not have it yet and tsc reports Cannot find module '@app/api/rpcGroup'. voltro dev generates it on boot; run voltro codegen once for a fresh clone or a CI job that only typechecks. The scaffolded api templates do this in their own typecheck script, and pnpm -r runs the api before anything that depends on it.
The tag-taking hooks below are the primitive underneath. Reach for them when the tag is only known at runtime — plugin web bindings and libraries shipped against an unknown app — not in app code.
useSubscription(apiName, rpcTag, input?, options?)
Subscribes to a reactive defineQuery RPC.
const { data, error, revision, pendingPatches } = useSubscription(
'app',
'notes.list',
{ archived: false },
)Returns:
| Field | Meaning |
|---|---|
data |
Latest typed query output; undefined while loading is true. |
loading |
true until the first snapshot arrives. Discriminates the result — see below. |
isEmpty |
The snapshot that arrived is empty. Always false while loading. |
error |
Subscription setup error, if no snapshot could be delivered. |
revision |
Server revision counter. |
emittedAt |
Timestamp for the latest server delta. |
pendingPatches |
Number of active optimistic patches applied to this cache entry. |
The result type SubscriptionState<T> is a discriminated union on loading:
type SubscriptionState<T> =
| { loading: true; data: undefined; isEmpty: false }
| { loading: false; data: T; isEmpty: boolean }
// both members also carry revision, emittedAt, error and pendingPatchesSo loading is a type guard, not a flag beside data — narrow on it and data
is T, with no ?? [] and no !:
const { data, loading } = useSubscription<Team[]>('app', 'teams.list')
if (loading) return <Skeleton/>
return <TeamsTable teams={data}/> // data is Team[]Because it is a union, an interface X extends SubscriptionState<...> does not
compile — TypeScript cannot extend a union. Intersect instead:
type TeamsState = SubscriptionState<ReadonlyArray<Team>> & { readonly canEdit: boolean }loading means no data has arrived yet, not "the subscription is still
warming up". A cold-start failure is its own state — loading: false,
failed: true, error non-optional — so branching on loading alone is safe;
render the failure off failed.
Use { skip } to defer until inputs are ready:
const { data } = useSubscription(
'app',
'messages.list',
{ channelId },
{ skip: channelId === undefined },
)Offline semantics with the local-first mirror
With @voltro/local-first's query mirror bound (see
the sync engine),
useSubscription's behaviour extends offline WITHOUT a second API: a cold
start seeds data (and revision) from the device's mirrored rows for the
subject's partition, so loading resolves against local data when the server
is unreachable; the first live event replaces it, and a reconnect inside the
resume window continues with deltas from the mirrored revision. Offline
WRITES ride useOutbox — durable
with outboxPersistence(), conflict resolution via resolveConflict.
useMutation(apiName, rpcTag)
Calls a defineMutation RPC.
const create = useMutation<{ title: string }, { id: string }>('app', 'notes.create')
await create.mutate({ title: 'Hello' })Returns:
| Field | Meaning |
|---|---|
mutate(input, options?) |
Calls the mutation and resolves the typed output. options is { onSuccess, onError, notify } — see Mutations. |
pending |
true while a call is in flight. |
error |
Last failure, or undefined. |
data |
Last successful result, or undefined. |
withOptimistic(fn) |
Override descriptor-derived optimistic patches. |
withoutOptimistic() |
Disable optimistic patches for this mutation handle. |
Custom optimistic example:
const create = useMutation('app', 'notes.create').withOptimistic((cache, input) => {
cache.forTag<ReadonlyArray<{ id: string; title: string }>>('notes.list', (rows) => [
{ id: `temp:${Date.now()}`, title: String(input.title) },
...rows,
])
})useAction(apiName, rpcTag)
Calls a defineAction RPC. Actions are unary like mutations, but the client has no optimistic/cache surface for them.
const invite = useAction<{ email: string }, { ok: boolean }>('app', 'invites.send')
await invite.run({ email })Returns run, pending, error, and lastResult.
run takes the same options bag as useMutation's mutate —
{ onSuccess, onError, notify }:
const invite = useAction('app', 'invites.send')
await invite.run({ email }, {
onSuccess: (out) => toast.success(`sent ${out.id}`),
onError: (e) => toast.error(readError(e)),
})Same load-bearing semantic: supplying an error handler (onError or
notify.error) marks the failure handled, so run resolves with undefined
instead of rejecting — that is what removes the try/catch. With no options it
rejects exactly as before, so unhandled failures stay loud.
The callback form is for single-shot writes. A loop or a multi-step sequence
relies on the promise throwing to stop; once the failure is handled the promise
resolves and the loop keeps going. Sequenced writes want the bare run(input)
plus a real try/catch — see Actions.
useWorkflow(apiName, workflowName)
Starts and controls a discovered *.workflow.tsx. Starting returns a WorkflowRunHandle immediately; it does not wait for the workflow's success payload.
const summarise = useWorkflow<{ noteId: string }>('app', 'notes.summarise')
const run = await summarise.start({ noteId })voltro codegen also emits workflow type maps from your *.workflow.tsx
definitions:
import type {
WorkflowMessages,
WorkflowPayloads,
WorkflowResults,
} from '@app/api/rpcGroup.generated'
const summarise = useWorkflow<
WorkflowPayloads['notes.summarise'],
WorkflowMessages['notes.summarise']
>('app', 'notes.summarise')
type SummaryResult = WorkflowResults['notes.summarise']Returns:
| Field | Meaning |
|---|---|
start(payload) |
Starts the durable workflow and resolves a run handle. |
cancel({ workflowName, executionId }) |
Cancels a running execution. |
resume({ workflowName, executionId }) |
Resumes a suspended execution. |
signal({ id }, signalName, payload?) |
Sends an external signal to a run id or execution id. |
update({ id }, updateName, payload?, options?) |
Sends a tracked update and resolves with the workflow handler result. |
pending |
true while start(...) is in flight. |
error |
Last start failure, or undefined. |
data |
Last returned run handle, or undefined. |
useWorkflowSignal(apiName)
Focused helper for signal buttons and approval forms that do not also start workflows:
const signal = useWorkflowSignal('app')
await signal.signal({ id: runId }, 'approval', { approved: true })It returns signal(...), pending, error, and the last { eventId }.
useWorkflowUpdate(apiName)
Focused helper for tracked workflow updates. Unlike signals, updates wait for the workflow's awaitUpdate(...) handler to validate and return a result:
const approve = useWorkflowUpdate('app')
const result = await approve.update(
{ id: runId },
'approve',
{ decision: true },
{ timeoutMs: 30_000 },
)It returns update(...), pending, error, and the last { eventId, updateId, completedEventId, result }.
useWorkflowRun(apiName, id)
Subscribes to Voltro's built-in reactive workflow-run query. id can be the durable executionId returned by useWorkflow().start(...) or the _voltro_workflow_runs.id from inspection data.
const { run, data, error } = useWorkflowRun('app', runId)run is the first row from the built-in query. It updates through the normal reactivity engine as _voltro_workflow_runs changes, so workflow status UIs do not need polling.
useWorkflowRuns(apiName, filters?, options?)
Subscribes to a bounded workflow-run list:
const { runs } = useWorkflowRuns('app', {
tag: 'notes.summarise',
status: 'running',
limit: 25,
})Filters are optional. limit defaults to 100 and is capped by the server, so app-level job centers do not accidentally subscribe to the entire run history.
useWorkflowRunSteps(apiName, runId)
Subscribes to the step timeline for one _voltro_workflow_runs.id:
const { steps } = useWorkflowRunSteps('app', liveRun?.id)Step rows update reactively as checkpointed step({...}) activities start, succeed, or fail.
useWorkflowRunEvents(apiName, runId)
Subscribes to the event timeline for one _voltro_workflow_runs.id:
const { events } = useWorkflowRunEvents('app', liveRun?.id)Events include lifecycle changes, timers, signals, and updates recorded by the workflow runtime.
useWorkflowEvents(apiName, runId) is a shorter alias for the same hook.
useWorkflowDomainEvents(apiName, filters?, options?)
Subscribes to the domain events an app emitted through ctx.events.publish(...) — the business-event log behind event triggers, not one run's internal timeline.
const { events } = useWorkflowDomainEvents('app', { name: 'order.paid', limit: 50 })Both filters are optional: name narrows to one event name, limit defaults to 100 and is clamped to 500 by the server. Rows arrive newest-first (by occurredAt), and each carries id, name, payload, source, subject, traceId, and occurredAt. The third argument is the standard SubscriptionOptions (e.g. { skip }).
Alongside events, the hook returns the normal subscription fields (data, error, revision, …).
useWorkflowEventDeliveries(apiName, eventId)
Subscribes to the fan-out of one emitted domain event: one row per trigger the event was routed to, so you can see which workflows a single emit(...) actually started.
const { deliveries } = useWorkflowEventDeliveries('app', selectedEvent?.id)
deliveries.map((d) => `${d.workflowName}: ${d.status}`)The subscription is skipped while eventId is undefined, so it pairs directly with a row selected out of useWorkflowDomainEvents. Rows are ordered oldest-first and carry eventId, eventName, triggerId, workflowName, executionId, idempotencyKey, skipped, errorMessage, createdAt, completedAt, and a status of 'starting', 'started', 'skipped', or 'failed'.
A skipped delivery is the normal outcome when a trigger's filter returned false or its idempotencyKey had already been seen — it is not a failure. errorMessage is set only on 'failed'.
useAgentStream(apiName, rpcTag)
Consumes a defineStream RPC. Despite the name, this hook is not limited to AI agents; it handles any one-shot element stream.
const ticker = useAgentStream<{ price: number }>('app', 'ticker.watch')
ticker.start({ symbol: 'BTC' })
ticker.cancel()Returns:
| Field | Meaning |
|---|---|
events |
Elements received so far, in order. |
status |
'idle', 'streaming', 'done', or 'error'. |
error |
Failure when status === 'error'. |
start(input?) |
Starts a new run and clears old events. |
cancel() |
Interrupts the in-flight run. |
useAgent(apiName, rpcTag)
Ergonomic wrapper over useAgentStream for transient AI chat streams. It derives tokens from token events and folds completed turns into history.
const support = useAgent('app', 'support.run')
support.send({ message: 'Help me', history: support.history })
support.cancel()For durable chat generated by defineAgent, use the normal pair: useSubscription('app', '<name>.messages', input) plus useAction('app', '<name>.send').
Connection Lifecycle
All hooks share the API WebSocket. Query subscriptions resubscribe after reconnect and get fresh snapshots. In-flight unary calls reject on disconnect. In-flight streams end with an error and must be started again.