Subscriptions
How reactive query subscriptions stay live over WebSocket.
A subscription is what the browser gets when it calls useSubscription(...) for a defineQuery RPC. The app code writes a query pair; the runtime keeps that query live over WebSocket and pushes new snapshots or deltas when matching data changes.
Subscriptions are not a separate file convention anymore. The file convention is queries: *.query.ts for the descriptor and *.query.server.ts for the executor.
Live — add a todo (or open this page in a second tab) and the list updates with
no refetch; the <DataTable> is a subscription under the hood:
<AutoForm api="app" mutation="todos.create" submitLabel="Add todo" />
<DataTable api="app" query="todos.list" /> {/* live subscription */}
Lifecycle
const { data } = useSubscription('app', 'notes.list', { archived: false })- The client opens or reuses the API WebSocket.
- It sends the query tag and input.
- The runtime runs
notes.list.query.server.ts. - The first value is delivered as a snapshot.
- Later mutations emit change events when their transaction commits.
- Matching query subscribers receive the updated value.
From React, data just changes. There is no refetch call.
Query Descriptor
// apps/api/queries/notes.list.query.ts
import { defineQuery } from '@voltro/protocol'
import { Schema } from 'effect'
export const listNotes = defineQuery({
name: 'notes.list',
source: 'notes',
input: Schema.Struct({ archived: Schema.Boolean }),
output: Schema.Array(Schema.Struct({
id: Schema.String,
title: Schema.String,
})),
})source declares which table re-runs computed queries and lets mutations with matching target metadata patch the client cache optimistically.
Query Executor
// apps/api/queries/notes.list.query.server.ts
import { eq } from '@voltro/database'
import { database } from '../database/schema'
export default (input: { archived: boolean }) =>
database.notes.where(eq('archived', input.archived)).orderBy('createdAt', 'desc')Descriptor-returning executors get fine-grained row matching. Computed-return executors can return arrays, objects, scalars, or null; they should declare source so the runtime knows what table should re-run them.
skip
Use { skip } when the input is not ready yet:
const { data } = useSubscription(
'app',
'messages.list',
{ channelId },
{ skip: channelId === undefined },
)While skipped, no WebSocket subscription opens and data stays undefined.
Skipped is idle, not loading
A skipped subscription reports idle: true, loading: false. The two are
different questions — "waiting for the first snapshot" and "not asking at all" —
and conflating them breaks the pattern this hook otherwise blesses:
// WRONG on a skipping call site — renders a skeleton for a query you switched off
if (loading) return <Skeleton/>Passing a dynamic skip therefore changes the return type: you get a third
state, and TypeScript will not let you ignore it.
const s = useSubscription<Team[]>('app', 'teams.list', {}, { skip: !open })
if (s.idle) return null // deliberately not asking
if (s.loading) return <Skeleton/> // asking, no answer yet
return <TeamsTable teams={s.data}/> // `data` is Team[] — narrowedCall sites that never skip are untouched — if (loading) still proves data is
present there, and a literal { skip: false } counts as never skipping. The
third state exists only where "not asking" is a real outcome.
With a fallback there is nothing to narrow either way: data is always
present, and idle tells you whether what is on screen is the fallback because
you chose not to ask.
A subscription that was live and is then skipped goes idle — it does not
keep serving the snapshot it still holds. Otherwise skip: !open would show
last time's data the moment a dialog reopens.
Streams Are Different
For non-database or transient element feeds, use streams, not subscriptions:
const ticker = useAgentStream('app', 'ticker.watch')
ticker.start({ symbol: 'BTC' })Queries/subscriptions are for live state. Streams are for one-shot element flows such as tokens, progress events, and import logs.
Reconnect
On reconnect, the client re-subscribes to active queries and receives a fresh snapshot. Optimistic patches are client-local and are reverted when their mutation settles.
Tenant Scoping
Tables with the tenant() mixin are scoped by the runtime using ctx.request.subject.tenantId. Do not add duplicate tenant predicates in query executors unless you are deliberately narrowing further.
Inspect
The devtools subscription surfaces show active subscribers, recent deltas, and cache state. Use them when a query updates too often or not at all.
See also
- Subscribers (
*.subscribe.ts) — server-side, best-effort post-commit reactivity to a table (NOT the client hook on this page). - Streams — transient, non-database element feeds (no snapshot/reconnect replay).