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.
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 leaves loading true and sets error, so a
component branching on loading alone renders a skeleton forever — check
error.
Use { skip } to defer until inputs are ready:
const { data } = useSubscription(
'app',
'messages.list',
{ channelId },
{ skip: channelId === undefined },
)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.emit(...) — 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.