@locel/tanstack
@locel/tanstack — bind a replica to TanStack DB collections.
Binds a locel replica to TanStack DB. locel owns the store and optimism; TanStack DB owns queries — live joins, incremental recomputation, framework bindings. Neither half reimplements the other.
It is built entirely on the replica's public
read surface — get, all, count and
subscribe on a collection handle — and on replica.transaction for writes.
There is no private channel between the two packages, which is what makes
"TanStack DB owns queries" a choice rather than a requirement: a different query
layer binds the same way.
Status: Built.
Peer dependency on @tanstack/db. It lives in its own package so that
@locel/client has no dependency on TanStack DB at all — a replica is usable
without it, and this binding is the only thing that has to track its API.
locelCollectionOptions(options)
function locelCollectionOptions<C extends CollectionDefinition>(
options: LocelCollectionOptions<C>
): CollectionConfigPass the result to createCollection:
import { createCollection } from '@tanstack/db'
import { locelCollectionOptions } from '@locel/tanstack'
export const todosCollection = createCollection(
locelCollectionOptions({ replica, collection: todos })
)| Option | Type | Default | Description |
|---|---|---|---|
replica | Replica | — | Required. |
collection | CollectionDefinition | — | Required. Must belong to the replica's schema. |
id | string | the collection name | What devtools show. |
schema | Standard Schema | — | Client-side validation richer than locel's structural check. Not a security boundary. |
settle | 'confirmed' | 'durable' | 'confirmed' | What TanStack DB's isPersisted.promise means. |
settle and the magic columns
Worth knowing before you build a "not yet saved" indicator.
TanStack DB applies its own optimistic value while a mutation is in flight, and
for an insert that value is the input you passed — which has no magic columns on
it. So with settle: 'confirmed' a freshly inserted row reads as having no
$pending until the server answers, at which point $pending is already false.
With settle: 'durable' the mutation completes as soon as locel has the write,
TanStack's overlay drops, and what a query sees is locel's row — carrying
$pending: true for as long as the write is unsent. If you query $pending,
use settle: 'durable'. It is the right choice for an app with long offline
sessions anyway, for the reason below.
Persistence is off, and stays off
locelCollectionOptions configures the collection so TanStack DB does not
persist anything. The replica is already the durable store; a second one would
be a second opinion about what your user wrote, in a different transaction, with
no way to reconcile the two when they disagree.
Wrapping the result in persistedCollectionOptions is not supported and will
raise E_COLLECTION_ALREADY_PERSISTED at collection creation.
settle
'confirmed' — the default — resolves TanStack DB's transaction when the server
has answered, so await tx.isPersisted.promise means the server took it.
'durable' resolves as soon as the write is in the replica. Choose it for apps
with long offline sessions, where the confirmed promise would otherwise stay
pending for the whole trip. A later refusal still surfaces in
replica.rejections and through replica.onRejection — it just does not come
back through a promise you already resolved.
Sync mode
The collection follows the replica's mode. With mode: 'subset', the returned
options include syncMode: 'on-demand' and an onLoadSubset handler, so
TanStack DB pushes each live query's where, orderBy and limit down and
locel turns them into a subset.
Extraction is deliberately partial: eq, gt, gte, lt, lte and and are
pushed down; everything else stays in TanStack DB's own predicate, which runs
over the rows locel delivers. A missed comparison costs a wider subset, never a
wrong result. Under or nothing is extracted, because neither branch bounds the
result. A comparison written the other way round — eq(50, todo.position) — is
read as the same filter, flipped.
In development the adapter reports the indexes declaration a subset would need
when none can serve it, instead of only raising E_MISSING_SUBSET_INDEX at open.
Same check, printed where you can act on it.
Subset ids are minted per load call rather than derived from the predicate. A derived id would make two queries that happen to agree share a lifetime they do not share — the first to unmount would close the subset out from under the second. One live query can produce more than one load, and so more than one subset; each is released by its own unload, and the replica reference-counts them, so the rows are held until the last one lets go.
The hook is TanStack DB's loadSubset/unloadSubset, returned from sync.
Row updates
The returned config sets rowUpdateMode: 'full'. locel hands back whole rows
because it rebases from confirmed state rather than accumulating patches, so
there is never a partial value to merge.
collection.utils
todosCollection.utils.confirmed(key) // Row | undefined — what the server last said
todosCollection.utils.isPending(key) // boolean
todosCollection.utils.sync() // Promise<void>
todosCollection.utils.whenIdle() // Promise<void>
todosCollection.utils.replica // the underlying Replica
todosCollection.utils.status // ReplicaStatus
todosCollection.utils.writer // stringThese forward to the collection handle and the
replica; none of them do work of their own. confirmed(key) is the one worth
knowing — server state ignoring pending local writes, which is what a "discard
my changes" affordance needs. isPending(key) reads the $pending magic column.
Querying sync state
The magic columns are ordinary columns to TanStack DB, so every question about sync is a live query:
const unsent = createLiveQueryCollection((q) =>
q.from({ todo: todosCollection }).where(({ todo }) => eq(todo.$pending, true))
)
const merged = createLiveQueryCollection((q) =>
q.from({ todo: todosCollection }).where(({ todo }) => todo.$conflict != null)
)This is why locel ships no React package. useLiveQuery over $pending is
better than a useRowStatus hook, and it is one fewer thing to keep correct.
The division of labour is ADR 0012, which supersedes the one the prototype recorded.