loceldocumented
Reference

@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 surfaceget, 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>
): CollectionConfig

Pass the result to createCollection:

import { createCollection } from '@tanstack/db'
import { locelCollectionOptions } from '@locel/tanstack'

export const todosCollection = createCollection(
  locelCollectionOptions({ replica, collection: todos })
)
OptionTypeDefaultDescription
replicaReplicaRequired.
collectionCollectionDefinitionRequired. Must belong to the replica's schema.
idstringthe collection nameWhat devtools show.
schemaStandard SchemaClient-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             // string

These 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.

On this page