Client SDK
useQuery, useMutation, useAction.
@helipod/client is the framework-agnostic client that runs the reactive sync protocol underneath
your app: a transport, a HelipodClient built on top of it, and (via @helipod/client/react)
hooks that subscribe a component to a query and re-render it on every push.
This page covers the whole client surface: construction, transports, every HelipodClient method,
the React hooks, the wire lifecycle, auth, typed codegen, and error handling. For deep dives on the
two biggest features built on this client, see Optimistic updates
and Offline sync.
Construct a client
HelipodClient takes a transport (the thing that actually carries the sync protocol) plus
optional configuration:
import { HelipodClient, webSocketTransport } from "@helipod/client";
const wsProtocol = location.protocol === "https:" ? "wss" : "ws";
const client = new HelipodClient(webSocketTransport(`${wsProtocol}://${location.host}/api/sync`));HelipodClient itself never touches the network directly. Every method (subscribe, query,
mutation, action, setAuth) goes through the transport. That's what makes an embedded, in-process
connection (loopbackTransport) and a networked one (webSocketTransport) behave identically from
the app's point of view.
Constructor options
The second constructor argument configures durable offline sync and a few knobs most apps never need to touch:
new HelipodClient(transport, {
outbox: indexedDBOutbox(), // opt into the durable offline outbox, see Offline sync
onClientReset: (info) => { ... }, // the server disowned this client's mutation history
onMutationFailed: (info) => { ... }, // a terminal durable failure with no live awaiter
poisonPolicy: "skip", // "skip" (default) or "pause", see Offline sync
outboxMaxQueueSize: 1000, // cap on unsettled durable entries (default 1000)
optimisticUpdates: { "messages:send": (store, args) => { ... } }, // registry for hydrated entries
gateTimeoutMs: 10_000, // internal reconcile-gate timeout (default 10s, rarely overridden)
});Everything outbox-related (outbox, poisonPolicy, outboxMaxQueueSize, outboxLocks,
outboxDeployment, outboxDrainIntervalMs, outboxChunkSize, outboxBroadcast,
outboxBackoffMs (a custom retry-delay function for the drain), onOutboxPause (fires when a
"pause" poison policy halts the drain), onClientReset, onMutationFailed, optimisticUpdates)
is documented in full in Offline sync. A client constructed with no
outbox behaves exactly as if none of this existed.
Transports
The client logic itself is transport-agnostic. There are two transports to pick between.
loopbackTransport(connection) is for an embedded, in-process connection: no network hop, used when
the client and the engine run in the same process (tests, @helipod/test, single-process
deployments). It wraps anything shaped like { send, onMessage, close } and never reconnects.
onReopen is simply absent, and the client treats a transport without it as one that doesn't support
reconnection.
webSocketTransport(url, options?) is for a real deployment, talking to helipod dev/helipod serve over the network:
import { webSocketTransport } from "@helipod/client";
const transport = webSocketTransport("wss://myapp.example.com/api/sync", {
reconnect: true, // default true
initialBackoffMs: 300, // default 300
maxBackoffMs: 30_000, // default 30s
});Prop
Type
Reconnection, in detail
The WebSocket transport reconnects by default. This is the behavior an app gets for free unless it
opts out with { reconnect: false }.
Backoff schedule
Each reconnect attempt waits an exponentially-growing, jittered delay, capped at maxBackoffMs.
Each attempt waits reconnectDelayMs(attempt, initialBackoffMs, maxBackoffMs). The schedule uses
equal jitter: half the exponential delay, plus up to another half at random. This never produces a
zero wait, which avoids a thundering-herd reconnect storm the instant a server comes back:
import { reconnectDelayMs } from "@helipod/client";
// half of min(maxBackoffMs, initialBackoffMs * 2**attempt), plus up to another half at random
const delay = reconnectDelayMs(attempt, 300, 30_000);reconnectDelayMs is exported directly so the schedule itself is testable without simulating a
socket.
What happens on reopen
onReopen fires once per successful reconnect, never for the very first connect. When it fires, the
client runs a fixed sequence, in order:
- Replay
SetAuth: ifsetAuth(token)was ever called, the same token is resent first, so every subscription that follows re-runs under the right identity. - Resubscribe every live query: every subscription the app still holds open is re-sent as a fresh
ModifyQuerySet, and the server's reply is adopted as a brand-new baseline regardless of version. There's no way to know what was missed while disconnected, so the client doesn't try to diff across the gap. It re-establishes from scratch. If a query result hasn't changed since disconnect, the server can answer with a lightweightQueryUnchangedinstead of resending the full value. See Subscription lifecycle below. - FIFO-flush only
unsentmutations: any mutation that was queued because the socket was down when it was called goes out now, oldest first, reusing its originalrequestIdso the promise created at the originalmutation()call site is the one that eventually resolves.
An inflight mutation (one whose Mutation frame had already been sent when the socket dropped) is a
different story. Its outcome is genuinely unknowable: the server may or may not have received and
committed it before the drop. Without a durable outbox configured, that promise rejects with
MutationUndeliveredError rather than being blindly resent, since a resend could double-apply a
mutation that actually succeeded. With a durable outbox configured and armed (see
Offline sync), the entry instead parks, and a later drain resolves it
exactly once via server-side dedup.
import { MutationUndeliveredError } from "@helipod/client";
try {
await client.mutation(api.messages.send, { body });
} catch (err) {
if (err instanceof MutationUndeliveredError) {
// the connection dropped before we learned whether this committed.
// without an outbox, it's on the app to decide whether re-sending is safe for this mutation.
}
}The pre-first-open case
While the very first connection attempt is still pending, the transport buffers frames and flushes
them the moment the socket opens. That covers normal connection latency. If that first attempt
fails before ever opening (constructed while offline, or a network blip on load), the buffered
frames are dropped, not held: the transport announces the close, and recovery moves up a level.
When a socket finally does open, the client runs the same reopen sequence (SetAuth replay,
resubscribe, flush unsent mutations) as a mid-session reconnect, rebuilding everything from its own
state rather than from replayed raw frames.
The same holds for any later down period: once a socket has opened at least once (with reconnect on), a frame sent while disconnected is deliberately dropped rather than buffered, because the reopen sequence re-derives everything it could have represented. From the app's point of view there is exactly one code path for "the client now has a live connection and needs to rebuild state from what it remembers": first connect after a failed attempt, and every subsequent reconnect.
Wrap your app in the provider
The React hooks read the client from context:
import { HelipodProvider } from "@helipod/client/react";
createRoot(root).render(
<HelipodProvider client={client}>
<Chat />
</HelipodProvider>,
);useHelipodClient() reads it back out. It throws if called outside a HelipodProvider (every
other hook below is built on it):
import { useHelipodClient } from "@helipod/client/react";
function DebugPanel() {
const client = useHelipodClient();
return <button onClick={() => client.close()}>Disconnect</button>;
}Subscribe with useQuery
useQuery takes a generated function reference (api.messages.list) and its arguments, and returns
the current result: undefined until the first result arrives.
import { useQuery } from "@helipod/client/react";
import { api } from "../helipod/_generated/api";
function Chat({ conversationId }: { conversationId: string }) {
const messages = useQuery(api.messages.list, { conversationId });
if (messages === undefined) return <p>connecting…</p>;
return (
<ul>
{messages.map((m) => (
<li key={m._id}>{m.author}: {m.body}</li>
))}
</ul>
);
}Passing a codegen api.* reference infers typed args and a typed return value from the query's
declared args/returns validators (see Typed codegen api below). Under the
hood, useQuery calls client.subscribe(...) in an effect and re-subscribes only when the reference
or its (serialized) arguments change. The subscription is torn down automatically when the component
unmounts.
There's no manual refresh, no polling interval, and nothing to invalidate by hand. The component re-renders on its own whenever the query's read set is touched by a committed write. See Queries and How it works for why that works.
The lower-level client: subscribe, query, mutation, action
useQuery/useMutation/useAction are thin wrappers over HelipodClient methods you can also
call directly. That's useful outside React, or when you need a one-shot read rather than a live
subscription.
client.subscribe(ref, args, onUpdate, onError?)
The primitive underneath useQuery. Subscribes to a query. onUpdate fires with the latest value
(synchronously, if a cached value is already on hand) every time it changes, and the optional
onError fires if the query's handler throws server-side. Returns an unsubscribe function:
const unsubscribe = client.subscribe(
api.messages.list,
{ conversationId },
(messages) => console.log("updated:", messages),
(error) => console.error("query failed:", error),
);
// later
unsubscribe();Calling subscribe twice with the same function reference and (serialized) arguments dedupes onto
one underlying server subscription. Each call gets its own listener, and the server subscription is
torn down only once every listener has unsubscribed. If a subscribing query throws, its last known
value stays in place for any caller that only registered onUpdate. A failing query is otherwise
just logged.
client.query(ref, args?)
A one-shot read: resolves with the first value delivered (which may already be an actively-updating composed value if an optimistic layer is in play), or rejects if the query throws. Then it unsubscribes automatically:
const messages = await client.query(api.messages.list, { conversationId });Use useQuery/subscribe when you want live updates. Use query for a single read, for example
inside an event handler, or outside a React tree entirely.
client.mutation(ref, args?, opts?)
Runs a mutation. It resolves with its return value at commit (see Promise timing below), or rejects with its error:
await client.mutation(api.messages.send, { conversationId, author, body });opts accepts:
optimisticUpdate: render a predicted result before the round trip completes. See Optimistic updates for the fullOptimisticLocalStoreAPI, purity rules, and the no-flicker reconciliation contract.transient: true: skip the durable offline outbox for this call only, even when anoutboxis configured on the client. It's meant for mutations that must never be durably replayed after a reload. The canonical example is an auth token refresh: blindly replaying a stale refresh token trips reuse-detection and force-signs-out an honest user. A client with nooutboxconfigured is unaffected either way.
await client.mutation(api.auth.refresh, { refreshToken }, { transient: true });client.action(ref, args?)
Runs an action and resolves with its return value (or rejects with its error). Actions aren't reactive: there's no subscription here, just a single call and response.
await client.action(api.users.sendWelcomeEmail, { userId });Call functions with useMutation and useAction
useMutation returns a callable that runs a mutation and resolves with its return value:
import { useMutation } from "@helipod/client/react";
function Chat() {
const send = useMutation(api.messages.send);
function submit(body: string) {
void send({ conversationId, author, body });
}
// ...
}.withOptimisticUpdate(fn) chains onto the callable useMutation returns, producing a new callable
with the updater bound. It doesn't mutate the original, so useMutation(ref) itself stays reusable
without an optimistic update:
const send = useMutation(api.messages.send).withOptimisticUpdate((store, args) => {
const existing = store.getQuery(api.messages.list, { conversationId: args.conversationId }) ?? [];
store.setQuery(api.messages.list, { conversationId: args.conversationId }, [
...existing,
{ _id: store.placeholderId("messages"), _creationTime: store.now(), ...args },
]);
});Calling .withOptimisticUpdate repeatedly with the same updater function reference across renders
returns the same bound callable, with no identity churn, as long as that reference itself is stable
(for example module-scoped, or memoized with useCallback). A fresh inline closure every render
churns by necessity, since there's no way to detect "this is the same update" without a reference.
Full coverage, including the store's getQuery/setQuery, placeholderId()/now() purity rules,
and the no-flicker reconciliation contract, is in Optimistic updates.
useAction mirrors useMutation for actions: the side-effecting functions that run outside the
transaction (fetch, timers, and so on):
import { useAction } from "@helipod/client/react";
const sendWelcomeEmail = useAction(api.users.sendWelcomeEmail);
await sendWelcomeEmail({ userId });Unlike useQuery, neither useMutation nor useAction subscribes to anything. Calling the returned
function runs the mutation or action once and returns a promise.
Subscription lifecycle and wire messages
A subscribe()/useQuery() call sends a ModifyQuerySet frame adding the query. Unsubscribing (or a
component unmounting) sends one removing it. In between, the server pushes Transition frames:
batches of modifications, one per changed subscription, bracketed by a version pair so the client can
detect a dropped frame and resync from scratch rather than silently miss an update. Each modification
is one of:
| Wire type | When | What the client does |
|---|---|---|
QueryUpdated | The query has a new (or its first) value | Store the value, deliver it to every listener. |
QueryFailed | The query's handler threw | Deliver the error to onError; the last known value (if any) stays in place for onUpdate listeners. |
QueryUnchanged | A resubscribe's fresh re-run matched the value the client already had | Keep the existing value. No bytes for the value itself cross the wire. This is what lets a reconnect resubscribe skip resending unchanged data. |
QueryDiff | An incremental row-level diff for certain query shapes (by-id lookups, index-range scans, paginated pages) | Apply the row changes to a keyed map instead of replacing the whole value; a checksum lets the client detect drift and fall back to a full resync. |
None of this is something app code touches directly. It's the mechanism useQuery's live updates and
reconnect's bandwidth savings are built on. The one part apps care about at the API level: onError
on subscribe() fires exactly once per QueryFailed, and the last delivered value is never cleared
out from under an onUpdate listener just because a later run failed.
Auth: setAuth
setAuth(token) sets (or, with null, clears) the caller's identity for the whole connection:
client.setAuth(sessionToken);
// ...
client.setAuth(null); // sign outSending SetAuth causes the server to re-run every live subscription under the new identity. Any
query whose result depends on ctx.auth (or on an identity-scoped db.get) re-answers with whatever
it evaluates to for the new caller, exactly like a write intersecting its read set. This is why the
reconnect sequence replays SetAuth before resubscribing (see
Reconnection above): the server must know the caller's identity before it
re-establishes subscriptions, not after.
For anything more than a raw bearer token (refresh scheduling, rotation, cross-tab session sharing),
use createAuthClient, the higher-level session manager built on top of setAuth. It owns calling
setAuth for you as tokens rotate, and (when a durable outbox is configured) derives the outbox's
identity fingerprint from the stable session id rather than the rotating access token, so a
mid-drain rotation never orphans queued offline mutations. See Auth for the
full API.
Typed codegen api
helipod codegen generates a typed api object from your helipod/ functions. That's what makes
api.messages.list a real, autocompleting, type-checked reference instead of a magic string.
anyApi and getFunctionPath
Underneath, a function reference is just { __path: string }: a module path plus a function name
("messages:list", or "admin/users:list" for a nested module). @helipod/client exports the
untyped proxy this is built on, anyApi, for hosts without codegen output at hand:
import { anyApi, getFunctionPath } from "@helipod/client";
const api = anyApi as Api; // your app's `_generated/server.ts` does exactly this
getFunctionPath(api.messages.list); // "messages:list"Every public entry point that accepts a function reference (client.query/mutation/subscribe/
action, useQuery/useMutation/useAction) also accepts a raw string path or the untyped anyApi
value directly. A generated typed api isn't required, just recommended.
In the same spirit, @helipod/client also exports an untyped mintDocumentId, the core mint
underneath codegen's typed mintId helper for client-supplied ids. Reach for it only in hosts
without codegen output; see Offline sync for the typed path.
FunctionReference, FunctionArgs, FunctionReturnType
Codegen's generated reference type carries phantom __args/__returns fields derived from a
function's declared args/returns validators. Two type helpers extract them:
import type { FunctionArgs, FunctionReturnType } from "@helipod/client";
type SendArgs = FunctionArgs<typeof api.messages.send>; // { conversationId, author, body }
type SendResult = FunctionReturnType<typeof api.messages.send>; // whatever `returns` declaresThis is what makes useQuery(api.messages.list, { conversationId })'s second argument type-checked
and its return value typed, with zero manual annotation. A function with no returns validator
resolves to any for its return type. The args side is always typed, since args is far more
commonly declared. Add a returns validator to narrow it (see
Queries and Optimistic updates,
which requires returns to type its store).
Error types
| Error | Thrown from | Meaning |
|---|---|---|
MutationUndeliveredError | A mutation's promise, on reconnect | The socket dropped after the Mutation frame was sent but before a response arrived, with no durable outbox to hand it off to. The outcome is genuinely unknown, so resending blindly could double-apply it. |
OfflineClientResetError | A durable outbox entry's promise | The server disowned this client's mutation history (ConnectAck{known: false}, a swept or foreign timeline). An entry that was in-flight at disconnect has no safe way to resend under a fresh identity, so it rejects loudly instead of guessing. Only relevant with a durable outbox configured, see Offline sync. |
OutboxOverflowError (.code === "OUTBOX_OVERFLOW") | client.mutation(), synchronously as a rejected promise | The durable outbox is at its cap (outboxMaxQueueSize, default 1000 unsettled entries). The new call is rejected rather than evicting an older queued entry, since an older entry may have no live promise awaiter at all (it could have survived a reload). |
import { MutationUndeliveredError, OfflineClientResetError, OutboxOverflowError } from "@helipod/client";
try {
await client.mutation(api.messages.send, { body });
} catch (err) {
if (err instanceof OutboxOverflowError) {
// back off, surface a "too many pending changes" banner, etc.
} else if (err instanceof OfflineClientResetError) {
// this specific in-flight mutation's fate is unknowable after a reset.
// decide whether to retry.
} else if (err instanceof MutationUndeliveredError) {
// no outbox configured. the reconnect dropped this one.
}
}A plain mutation-handler error (your own throw new Error(...) inside the function) surfaces as an
ordinary Error with an optional .code string, distinguishable from all three of the above by
instanceof.
onMutationFailed
For a durable outbox, a mutation can fail terminally with no live promise to reject. The call that
originally enqueued it may be long gone: a prior page load, a retried entry, or a failure discovered
already-recorded when the client starts up. onMutationFailed, passed at client construction, is how
an app hears about those:
new HelipodClient(transport, {
outbox: indexedDBOutbox(),
onMutationFailed: ({ clientId, seq, udfPath, error }) => {
console.error(`mutation ${udfPath} failed:`, error.message);
},
});Without a handler registered, a failure like this logs loudly to console.error in dev mode rather
than failing silently. It never double-fires for a failure the original caller's own promise already
saw reject this session. Combine it with usePendingMutations() (below) for a full pending-mutations
tray, see the recipe in Offline sync.
Observing pending mutations: usePendingMutations
usePendingMutations() returns a live, reactive snapshot of the durable outbox: [] with no outbox
configured, and forever after:
import { usePendingMutations } from "@helipod/client/react";
function PendingTray() {
const pending = usePendingMutations();
return (
<ul>
{pending.map((entry) => (
<li key={`${entry.clientId}:${entry.seq}`}>
{entry.udfPath}: {entry.status}
{entry.status === "failed" && (
<>
<button onClick={() => entry.retry()}>Retry</button>
<button onClick={() => entry.dismiss()}>Dismiss</button>
</>
)}
</li>
))}
</ul>
);
}Each entry's status is one of unsent/inflight/parked/completed/failed. retry()/
dismiss() are meaningful only on a failed entry (a terminal, server-recorded verdict) and are
harmless no-ops otherwise: retry() re-enqueues under a fresh (clientId, seq) rather than reviving
the old record, since a seq is never reused once minted. The hook re-reads on every local outbox
change and on a cross-tab BroadcastChannel nudge, so a second browser tab's queued mutations show up
here too. Non-React hosts get the same signal from client.onOutboxChange(listener), which fires on
every local outbox change and every cross-tab nudge, and returns an unsubscribe. The underlying client methods, client.pendingMutations() and client.pendingSummary()
(count plus oldest-age, for a "changes may be lost soon" banner), are documented alongside the full
durable-outbox model in Offline sync.
Notifications helpers
@helipod/client/react re-exports a small set of typed hooks for
@helipod/notifications's reactive in-app inbox. No per-app
codegen is required, since these hooks call the component's well-known function paths directly:
import { useNotifications, Inbox, useNotificationPreferences } from "@helipod/client/react";
function Bell() {
const { notifications, unreadCount, markRead, markAllRead } = useNotifications({ limit: 50 });
return (
<button onClick={() => markAllRead()}>
Inbox ({unreadCount})
</button>
);
}
// or the headless render-prop form:
<Inbox limit={50}>
{({ notifications, unreadCount, markRead }) => (
<ul>{notifications.map((n) => <li key={n._id} onClick={() => markRead(n._id)}>{n.title}</li>)}</ul>
)}
</Inbox>useNotificationPreferences() gives a live view of the caller's own per-category/per-channel
preferences plus a setter (setPreference({ category, channel, enabled })). registerForPush/
unregisterForPush are plain async functions, not hooks, since registration happens once at boot,
not on every render, for wiring a device's push token. Full server-side setup (channels, providers,
topics, digests) is in Notifications.
Ephemeral broadcasts
Presence and typing-indicator style events don't need durability or reactivity. They bypass the engine entirely and fan out to other connected clients:
client.publishEphemeral("typing", { conversationId, userId });
const unsubscribe = client.onBroadcast((topic, event) => {
if (topic === "typing") showTypingIndicator(event);
});These are fire-and-forget: no persistence, no read set, no replay on reconnect.
The mutation promise resolves at commit
client.mutation(...) (and therefore useMutation) resolves when the server's MutationResponse
arrives: at commit, once the write is durable, carrying the commit's timestamp.
Resolution and re-render can arrive in either order
Because the promise resolves at commit (not at the moment your own reactive feed has observed the
write), the promise's resolution and the query re-render triggered by the same write can arrive in
either order relative to each other. If you read freshly-committed state synchronously right after
await mutation(...), keep that in mind. See
Optimistic updates
for exactly how the two are sequenced and why, and the
migration guide if you are porting code that relied on
earlier resolution timing.
Closing a client
client.close();Stops the durable-outbox drain (if any), tears down the transport, and rejects every in-flight action
promise with a "connection closed" error. A closed client should be discarded: construct a new
HelipodClient (with a fresh transport) rather than reusing a closed instance.
Related
- Queries and Mutations: the server-side functions this client calls.
- Optimistic updates: instant local UI with exact rollback, the
OptimisticLocalStoreAPI, and the no-flicker reconciliation contract in full. - Offline sync: the durable outbox, backing stores, the drain, client-supplied ids, and the conflict taxonomy.
- Auth:
createAuthClient, the higher-level session manager built onsetAuth. - Notifications: the server-side setup behind the notifications hooks above.