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 every failed predecessor to preserve causality. NOT multi-device CRDT (single-user/device, honest about its limits).

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

Online enqueues and rehydration start replay automatically. Concurrent replay calls join one drain; edits appended while a send awaits acknowledgement join that drain in order. A transient failure retains the entry and its successors and is retried on the outbox's own clock after retryDelay(attempts) — by default defaultOutboxRetryDelay, 1 s doubling to 30 s with jitter. Coming back online, or replay(), retries at once. A server that is down while the network is up fires no online event, so waiting for one alone would leave the queue stuck. Rendering or adding another edit does not cut the wait short. Unmounting prevents further sends but cannot cancel or undo an already dispatched request.

Persistence across reloads. With persistence, successfully saved entries rehydrate on mount before newer writes are sent. Every transition requests a save (delivered entries are compacted away). The backing must serialize saves; enqueue() does not acknowledge a durable commit. A failing load or save silently degrades to in-memory behaviour: this is not a zero-data-loss promise. Server-side idempotency and ownership across tabs/users are separate concerns. The provided implementation is outboxPersistence() from @voltro/local-first, backed by the same PersistenceAdapter the sync engine drains — one durable queue per device, partitioned by who is signed in when you pass partition:

import { createIndexedDbPersistence, outboxPersistence } from '@voltro/local-first'

const adapter = await createIndexedDbPersistence()
const outbox = useOutbox({
  send,
  persistence: outboxPersistence(adapter, { partition: () => currentSubjectId }),
})

load reads the partition and returns only its writes; every save until the next load writes under that partition and never touches another subject's — so a write acknowledged while a sign-in is being switched stays with the subject who made it. Saves are serialised.

Resolving a conflict. A conflicted entry blocks everything behind it (causality). resolveConflict(id, input) replaces its input with the RESOLVED value, returns it to pending and replays — typically computed with resolveWithPolicy() from @voltro/local-first, where crdtText() columns merge and scalars follow the declared conflictPolicy():

outbox.resolveConflict(entry.id, resolveWithPolicy(policy, local, remote, {
  crdtColumns: ['body'],
}))

Bound to an api: the offline write lane

const outbox = useOutbox({ api: 'app', persistence })
outbox.enqueue('notes.create', { title: 'Written offline' })

With api, send defaults to that api's own mutation for the entry's tag, and four things come with it:

  • Idempotent resends. The entry id is the mutation's idempotency key, so a write the server committed whose answer was lost is not applied twice when it is resent (where the api has idempotency configured).
  • Previews that survive a reload. The mutation's declared targets stage a standing optimistic patch on every query of its table — open now, opened later, or rendered from the local-first mirror after a reload — until the write is sent (the server's echo replaces it) or conflicts (reverted).
  • The api's connectivity. The outbox drains when a write can land: the browser's signal AND the api's transport.
  • A queue per signed-in subject. A sign-in, sign-out or tenant switch drops the previous subject's queue from memory and loads the next one's from persistence — partition it with outboxPersistence(adapter, { partition }).

stamp: clock.now (a hybrid logical clock) records entry.stamp on every write, the local side's ordering key when a conflict is resolved.

Connectivity: useApiOnline

import { useApiOnline } from '@voltro/client'

const online = useApiOnline('app')   // or useApiOnline() for any mounted api

true when a write can reach the server: the browser's online/offline signal AND the api's own transport, so it reads false while the server is down although the network is up. A navigator that does not report onLine (React Native, Node) counts as online.