Query Collection
Query collections provide seamless integration between TanStack DB and TanStack Query, enabling automatic synchronization between your local database and remote data sources.
Overview
The @tanstack/query-db-collection package allows you to create collections that:
- Automatically fetch remote data via TanStack Query
- Support optimistic updates with automatic rollback on errors
- Handle persistence through customizable mutation handlers
- Provide direct write capabilities for directly writing to the sync store
Installation
npm install @tanstack/query-db-collection @tanstack/query-core @tanstack/dbBasic Usage
import { QueryClient } from "@tanstack/query-core"
import { DbClient, collectionOptions } from "@tanstack/db"
import { queryCollectionOptions } from "@tanstack/query-db-collection"
const queryClient = new QueryClient()
const db = new DbClient({ queryClient })
const todosCollection = collectionOptions("todos", (client) =>
queryCollectionOptions({
id: "todos",
queryKey: ["todos"],
queryFn: async () => {
const response = await fetch("/api/todos")
return response.json()
},
queryClient: client.requireDependency<QueryClient>("queryClient"),
getKey: (item) => item.id,
})
)
const todos = db.collection(todosCollection)Configuration Options
The queryCollectionOptions function accepts the following options:
Required Options
queryKey: The query key for TanStack Query. Can be a static array or a function that receivesLoadSubsetOptionsand returns a key. When using a function, all returned keys must share the base key (queryKey({})) as a prefix — see Query Key Prefix Convention.queryFn: Function that fetches data from the serverqueryClient: TanStack Query client instancegetKey: Function to extract the unique key from an item
Request-scoped QueryClient
queryCollectionOptions needs a queryClient. In SSR, TanStack Start, tests,
or multi-tenant apps, that client is request-local rather than module-global.
Put it on DbClient, then resolve it inside the collection descriptor factory:
import { QueryClient } from "@tanstack/query-core"
import { DbClient, collectionOptions } from "@tanstack/db"
import { queryCollectionOptions } from "@tanstack/query-db-collection"
interface Todo {
id: string
title: string
}
export const todoCollection = collectionOptions("todos", (client) =>
queryCollectionOptions<Todo>({
id: "todos",
queryKey: ["todos"],
queryFn: async () => {
const response = await fetch("/api/todos")
return response.json() as Promise<Array<Todo>>
},
queryClient: client.requireDependency<QueryClient>("queryClient"),
getKey: (todo) => todo.id,
})
)
export function createRequestClients() {
const queryClient = new QueryClient()
const dbClient = new DbClient({ queryClient })
return { queryClient, dbClient }
}dbClient.collection(todoCollection) memoizes one collection instance for that
descriptor and client. A second DbClient materializes fresh adapter state and
uses its own QueryClient.
Passing queryClient directly to queryCollectionOptions remains supported for
createCollection(...) and existing apps. When a descriptor is materialized,
an explicit DbClient dependency takes precedence; the configured
queryClient is the backwards-compatible fallback.
Business-Scoped Collection Factories
A tenant, project, account, or route parameter can define a business scope:
the server resource that a collection represents. Include the scope in the
descriptor id, Query key, and queryFn. This extends the
request-scoped QueryClient pattern with an
explicit scope parameter:
interface Todo {
id: string
title: string
projectId: string
}
async function fetchProjectTodos(projectId: string): Promise<Array<Todo>> {
const response = await fetch(`/api/projects/${projectId}/todos`)
return response.json()
}
function createProjectTodosDescriptor(
projectId: string,
) {
return collectionOptions(`project:${projectId}:todos`, (client) =>
queryCollectionOptions<Todo>({
id: `project:${projectId}:todos`,
queryKey: ["projects", projectId, "todos"],
queryFn: () => fetchProjectTodos(projectId),
queryClient: client.requireDependency<QueryClient>("queryClient"),
getKey: (todo) => todo.id,
})
)
}The scope is part of the descriptor identity. DbClient resolves separately
created descriptors with the same id to the same collection, so a React hook
can create the descriptor from its current parameters:
export function useProjectTodos(projectId: string) {
return useDbClient().collection(createProjectTodosDescriptor(projectId))
}Only the first descriptor for an id is materialized. Include every scope value
that changes the collection in both its descriptor id and Query key. Call
await dbClient.cleanup() when the client scope ends.
A business scope is separate from a relational subset requested by a live query. With syncMode: "on-demand", LoadSubsetOptions describes predicates, ordering, limits, and offsets within one business-scoped collection. These options reach queryFn through ctx.meta.loadSubsetOptions and determine the subset Query keys. See QueryFn and Predicate Push-Down.
Do not create a collection for each where, orderBy, or limit. Reuse the business-scoped collection and let on-demand loading represent those subsets. Create separate collections only for distinct server resources.
Query Options
Query Collections use TanStack Query internally and expose supported Query observer options as top-level queryCollectionOptions fields.
The following top-level Query Collection options are forwarded to the underlying Query observer:
select: Function that extracts the row array TanStack DB materializes from a wrapped Query responseenabled: Whether the query should automatically run (default:true)refetchInterval: Refetch interval in millisecondsretry: Retry configuration for failed queriesretryDelay: Delay between retriesstaleTime: How long data is considered freshgcTime: How long unused query data stays in the Query cacherefetchOnWindowFocus: Whether to refetch when the window regains focusrefetchOnReconnect: Whether to refetch when the network reconnectsrefetchOnMount: Whether to refetch when the observer mountsnetworkMode: Query network modeinitialData: Initial Query response for eager collectionsinitialDataUpdatedAt: Timestamp used by TanStack Query to determine initial data freshnessmeta: Metadata passed to the query function context. Query Collections may addloadSubsetOptionsfor on-demand queries.
const todosCollection = createCollection(
queryCollectionOptions({
queryKey: ["todos"],
queryFn: fetchTodos,
queryClient,
getKey: (todo) => todo.id,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
refetchOnMount: "always",
networkMode: "online",
})
)Top-level meta is always merged by Query Collection so it can add on-demand loadSubsetOptions. Other supported top-level Query options are only passed to TanStack Query when you define them. If you omit them, QueryClient.defaultOptions can still apply.
Some fields are owned or reinterpreted by the collection adapter rather than treated as ordinary Query option pass-through:
queryKey: Identifies the Query cache entry and, in on-demand mode, may be built from load-subset options.queryFn: Fetches the complete collection state or the requested on-demand subset.select: Extracts array rows from wrapped responses before they are stored in the collection. This is not the same contract as TanStack Query'sselectoption.queryClient: Supplies the Query client instance used by the collection.syncMode: Controls whether the collection syncs eagerly or on demand.getKey: Extracts each row's stable TanStack DB key.- Mutation handlers such as
onInsert,onUpdate, andonDelete.
Some TanStack Query fields are owned or reinterpreted by Query Collection and are intentionally not exposed as ordinary Query observer options:
queryKey,queryFn, andqueryClientselect(Query Collection uses this for row extraction, not TanStack Query's observer-levelselectcontract)meta(merged by Query Collection so on-demandloadSubsetOptionscan be included)subscribed(Query Collection owns the observer subscription lifecycle)structuralSharingandnotifyOnChangeProps(managed by Query Collection synchronization)
placeholderData is intentionally unsupported. TanStack Query treats placeholder data as observer-local presentation state rather than cached Query data. Materializing it would expose temporary UI data as collection-wide normalized rows. Render placeholders in the consuming UI instead.
Request Cancellation with QueryFunctionContext.signal
TanStack Query passes an AbortSignal to queryFn through the query function
context. Forward ctx.signal to fetch or another abortable client to make the
request cancellable:
const todosCollection = createCollection(
queryCollectionOptions({
queryKey: ["todos"],
queryFn: async (ctx) => {
const response = await fetch("/api/todos", {
signal: ctx.signal,
})
if (!response.ok) {
throw new Error("Failed to fetch todos")
}
return response.json() as Promise<Array<Todo>>
},
queryClient,
getKey: (todo) => todo.id,
}),
)Explicit collection cleanup cancels each exact Query key the collection is currently tracking before removing it from the Query cache:
await todosCollection.cleanup()The underlying request is aborted only when its client consumes ctx.signal.
A client that ignores the signal may continue its request even though the
collection has been cleaned up.
An unloaded on-demand subset is no longer tracked. A later explicit collection cleanup does not revisit its Query key.
Query cache entries are shared within a QueryClient. Explicit cleanup can
affect other consumers using the same exact Query keys.
On-demand subset unloading does not explicitly call
queryClient.cancelQueries(). It removes the subset's Query observer. If this
was the final observer and the query function consumed ctx.signal, TanStack
Query aborts the request. If the signal was ignored, or another observer still
uses the same exact Query key, the request may finish and remain cached until
gcTime.
Using with queryOptions(...)
If your app already uses TanStack Query's queryOptions helper (e.g. from @tanstack/react-query), you can spread compatible top-level options into queryCollectionOptions. Note that queryFn must be explicitly provided since query collections require it both in types and at runtime, and Query Collection's select option is for row extraction rather than TanStack Query observer-level selection:
import { QueryClient } from "@tanstack/query-core"
import { DbClient, collectionOptions } from "@tanstack/db"
import { queryCollectionOptions } from "@tanstack/query-db-collection"
import { queryOptions } from "@tanstack/react-query"
const queryClient = new QueryClient()
const db = new DbClient({ queryClient })
const listOptions = queryOptions({
queryKey: ["todos"],
queryFn: async () => {
const response = await fetch("/api/todos")
return response.json() as Promise<Array<{ id: string; title: string }>>
},
})
const todosCollection = collectionOptions("todos", (client) =>
queryCollectionOptions({
id: "todos",
...listOptions,
queryFn: (context) => listOptions.queryFn!(context),
queryClient: client.requireDependency<QueryClient>("queryClient"),
getKey: (item) => item.id,
}),
)
const todos = db.collection(todosCollection)If queryFn is missing at runtime, queryCollectionOptions throws QueryFnRequiredError.
Initial Data
Eager Query Collections support TanStack Query's initialData and
initialDataUpdatedAt options. Initial data has the original Query response
shape, is stored in the Query cache, and is immediately materialized as
normalized collection rows. TanStack Query uses initialDataUpdatedAt together
with staleTime to decide whether to fetch.
const serverRenderedAt = Date.now()
const initialTodos = [
{ id: "1", title: "Write documentation" },
{ id: "2", title: "Ship initial data support" },
]
const todosCollection = createCollection(
queryCollectionOptions({
queryKey: ["todos"],
queryFn: fetchTodos,
queryClient,
getKey: (todo) => todo.id,
initialData: initialTodos,
initialDataUpdatedAt: serverRenderedAt,
staleTime: 60_000,
}),
)An existing cached or hydrated Query response takes precedence over
initialData. Query keys remain the cache identity: two collections using the
same QueryClient and exact Query key observe one shared Query document, and a
later collection's initializer does not replace it. Use distinct Query keys for
independent documents.
Initial data is supported only for eager collections. A collection-wide value
cannot establish row membership for arbitrary on-demand predicates, ordering,
limits, and offsets. For syncMode: "on-demand", seed or hydrate the exact
derived Query cache entries instead.
If a stale initial response triggers a fetch, the initial rows remain available while it is in flight. A successful response reconciles them through the normal row ownership pipeline; an error retains the initial rows. Direct writes patch the eager Query cache in place when its row array can be updated. Collections sharing that Query project the same patched cache. On-demand direct writes patch existing cached row keys on a best-effort basis, as described below.
Selecting Rows from Wrapped Responses
Many APIs return rows inside a response envelope that also contains metadata such as pagination cursors, totals, or request information. Use select to extract the row array that TanStack DB should materialize:
interface TodosResponse {
items: Array<{ id: string; title: string }>
nextCursor?: string
total: number
}
const todosCollection = createCollection(
queryCollectionOptions({
queryKey: ["todos"],
queryFn: async (): Promise<TodosResponse> => {
const response = await fetch("/api/todos")
return response.json()
},
initialData: {
items: [{ id: "1", title: "Initial todo" }],
nextCursor: undefined,
total: 1,
},
select: (response) => response.items,
queryClient,
getKey: (item) => item.id,
}),
)select is a query-db-collection row extraction hook. It tells TanStack DB which rows to materialize while the TanStack Query cache keeps the original query response shape. In the example above, queryClient.getQueryData(["todos"]) still returns the full TodosResponse, including nextCursor and total.
The same projection applies to initialData: provide the complete response
envelope, and Query Collection materializes the rows returned by select while
preserving the envelope in the Query cache.
This differs from TanStack Query's observer-level select: query-db-collection uses this option to bridge Query's response object into DB's normalized row store.
In eager mode, direct write utilities such as writeInsert, writeUpdate, and writeDelete make a best-effort attempt to update the matching row array inside wrapped Query cache entries while preserving wrapper metadata. Collections using that exact Query key project the patched cache, including a newly inserted row. In on-demand mode, direct writes patch rows already present in active raw arrays or arrays exposed as a direct response property by select. They remove inactive entries under the Collection's query-key prefix unless another Collection or observer retains them. They do not reconstruct a scoped result from the full collection. A retained outside observer can therefore keep an inactive cache entry with old rows.
In both modes, a direct cache patch leaves Query's error, invalidation, and
freshness state intact. A direct write is not a successful fetch: an eager
query that failed still reports that error, and a stale eager query may fetch
on remount. Use utils.clearError() to retry after an error, or
utils.refetch() when you need fresh server results.
This works automatically when select returns the wrapper's array property itself, as in these simple wrappers:
{ data: [...] }{ items: [...] }{ results: [...] }
Derived projections, such as select: (response) => response.edges.map((edge) => edge.node), are read-side row extraction only. query-db-collection cannot generally reconstruct the original response envelope from updated rows, so direct writes leave that cached envelope unchanged. The Collection still receives the direct write, and resubscribing does not replay the unchanged envelope over it. Refetch or invalidate the query if the wrapped cache must reflect that write.
This also applies to derived arrays such as select: (response) => response.data.filter(Boolean).
Collection Options
id: Unique identifier for the collectionschema: Schema for validating itemssync: Custom sync configurationstartSync: Whether to start syncing immediately (default:true)
Persistence Handlers
onInsert: Handler called before insert operationsonUpdate: Handler called before update operationsonDelete: Handler called before delete operations
Extending Meta with Custom Properties
The meta option allows you to pass additional metadata to your query function. By default, Query Collections automatically include loadSubsetOptions in the meta object, which contains filtering, sorting, and pagination options for on-demand queries.
loadSubsetOptions is request-scoped. Query functions can read it through
ctx.meta, but it is excluded when Query metadata is serialized for SSR.
Hydrated Query metadata therefore does not contain the server request options;
new on-demand requests supply their own options. Other enumerable meta
properties retain TanStack Query's normal dehydration behavior.
Treat ctx.meta.loadSubsetOptions and its nested request data as read-only.
Do not edit expression nodes, ordering options, Dates, byte arrays, or membership
arrays. Build separate API parameters instead. Core retains request data without
cloning it; changing submitted data can make the request disagree with its cache
key. To change a query constant, supply a new value rather than mutating the old
one. Cancellation through the request's AbortSignal remains supported.
Type-Safe Meta Access
The ctx.meta.loadSubsetOptions property is automatically typed as LoadSubsetOptions without requiring any additional imports or type assertions:
import { parseLoadSubsetOptions } from "@tanstack/query-db-collection"
const collection = createCollection(
queryCollectionOptions({
queryKey: ["products"],
syncMode: "on-demand",
queryFn: async (ctx) => {
// ✅ Type-safe access - no @ts-ignore needed!
const options = parseLoadSubsetOptions(ctx.meta?.loadSubsetOptions)
// Use the parsed options to fetch only what you need
return api.getProducts(options)
},
queryClient,
getKey: (item) => item.id,
})
)Adding Custom Meta Properties
You can extend the meta type to include your own custom properties using TypeScript's module augmentation:
// In a global type definition file (e.g., types.d.ts or global.d.ts)
declare module "@tanstack/query-db-collection" {
interface QueryCollectionMeta {
// Add your custom properties here
userId?: string
includeDeleted?: boolean
cacheTTL?: number
}
}Once you've extended the interface, your custom properties are fully typed throughout your application:
const collection = createCollection(
queryCollectionOptions({
queryKey: ["todos"],
queryFn: async (ctx) => {
// ✅ Both loadSubsetOptions and custom properties are typed
const { loadSubsetOptions, userId, includeDeleted } = ctx.meta
return api.getTodos({
...parseLoadSubsetOptions(loadSubsetOptions),
userId,
includeDeleted,
})
},
queryClient,
getKey: (item) => item.id,
// Pass custom meta alongside Query Collection defaults
meta: {
userId: "user-123",
includeDeleted: false,
},
})
)Important Notes
- The module augmentation pattern follows TanStack Query's official approach for typing meta
QueryCollectionMetais an interface (not a type alias), enabling proper TypeScript declaration merging- Your custom properties are merged with the base
loadSubsetOptionsproperty - All meta properties must be compatible with
Record<string, unknown> - The augmentation should be done in a file that's included in your TypeScript compilation
Example: API Request Context
A common use case is passing request context to your query function:
// types.d.ts
declare module "@tanstack/query-db-collection" {
interface QueryCollectionMeta {
authToken?: string
locale?: string
version?: string
}
}
// collections.ts
const productsCollection = createCollection(
queryCollectionOptions({
queryKey: ["products"],
queryFn: async (ctx) => {
const { loadSubsetOptions, authToken, locale, version } = ctx.meta
return api.getProducts({
...parseLoadSubsetOptions(loadSubsetOptions),
headers: {
Authorization: `Bearer ${authToken}`,
"Accept-Language": locale,
"API-Version": version,
},
})
},
queryClient,
getKey: (item) => item.id,
meta: {
authToken: session.token,
locale: "en-US",
version: "v1",
},
})
)Persistence Handlers
You can define handlers that are called when mutations occur. These handlers persist changes to your backend:
const todosCollection = createCollection(
queryCollectionOptions({
queryKey: ["todos"],
queryFn: fetchTodos,
queryClient,
getKey: (item) => item.id,
onInsert: async ({ transaction }) => {
const newItems = transaction.mutations.map((m) => m.modified)
await api.createTodos(newItems)
// Auto-refetch happens after handler completes (pre-1.0 behavior)
},
onUpdate: async ({ transaction }) => {
const updates = transaction.mutations.map((m) => ({
id: m.key,
changes: m.changes,
}))
await api.updateTodos(updates)
},
onDelete: async ({ transaction }) => {
const ids = transaction.mutations.map((m) => m.key)
await api.deleteTodos(ids)
},
})
)Note: QueryCollection currently auto-refetches after handlers complete. See Controlling Refetch Behavior for details on this transitional behavior.
Controlling Refetch Behavior
⚠️ Transitional API: QueryCollection currently auto-refetches after handlers complete. This behavior is deprecated and will be removed in v1.0. See the migration notes below.
Current Behavior (Pre-1.0)
By default, QueryCollection automatically refetches after each handler completes. To skip auto-refetch, return { refetch: false }:
onInsert: async ({ transaction }) => {
await api.createTodos(transaction.mutations.map((m) => m.modified))
// Skip auto-refetch - use this when server doesn't modify the data
return { refetch: false }
}If you don't return { refetch: false }, auto-refetch happens automatically.
v1.0 Behavior (Future)
In v1.0, auto-refetch will be removed. Handlers will need to explicitly call collection.utils.refetch() when refetching is needed:
onInsert: async ({ transaction, collection }) => {
await api.createTodos(transaction.mutations.map((m) => m.modified))
// Explicitly trigger refetch when you need server state
await collection.utils.refetch()
}When no user mutation is persisting and no mutation handler is active, the
refetch() promise resolves after every accepted result from that call has been
applied to Collection rows. This is also the application barrier when the
result has no row diff and therefore emits no change event. Rejection is
fail-fast: with several tracked Queries, one failure may reject the call while
independent work remains pending.
While a user mutation is persisting or a Collection mutation handler is active,
normal application is queued behind that transaction. To avoid a circular wait
or an unrelated persistence delay, all overlapping refetch() calls resolve at
the Query fetch boundary. This rule applies whether the mutation or the refetch
starts first. It includes calls through the handler parameter, captured or
external Collection references, manual transaction mutation functions, and
createOptimisticAction mutation functions. Code can await the mutation's
isPersisted.promise when it needs the complete transaction and its queued
Collection application. Utilities that refetch, including clearError(),
inherit the same phase boundary.
The handler's collection parameter and every matching
transaction.mutations[n].collection alias preserve the external Collection
identity. Strict equality, Map, and WeakMap lookups therefore continue to
work in shared persistence handlers. Phase tracking changes the refetch wait
boundary without replacing the Collection, transaction, or mutation objects.
At the application boundary, throwOnError applies to both Query fetch errors
and errors applying an accepted result to Collection rows. With
throwOnError: true, application failure or cancellation rejects the call;
cancellation uses an AbortError. With throwOnError: false (the default),
refetch() returns its Query results instead. At the mutation-phase fetch
boundary, throwOnError applies only to the Query fetch because application
happens after that call settles. A later application failure is recorded by the
Collection's error utilities.
To skip refetch in v1.0, simply don't call refetch():
onInsert: async ({ transaction }) => {
await api.createTodos(transaction.mutations.map((m) => m.modified))
// No refetch call = no refetch (v1.0 behavior)
}When to Skip Refetch
Skip refetching when:
- You're confident the server state exactly matches what you sent (no server-side processing)
- You're handling state updates through other mechanisms (like WebSockets or direct writes)
- You want to optimize for fewer network requests
Utility Methods
The collection provides these utility methods via collection.utils:
refetch(opts?): Refetch every tracked Query and await the applicable fetch or application boundaryopts.throwOnError: Whether Query fetch or Collection application errors reject an application-boundary call (default:false); mutation-phase calls cover fetch errors only- Bypasses
enabled: falseto support imperative/manual refetching patterns (similar to hookrefetch()behavior) - Returns the tracked Queries'
QueryObserverResultvalues in tracked-key order - Preserves an
undefinedslot if a tracked Query is removed while the refetch starts
clearError(): Refetch with errors enabled; retain the recorded error until a successful result applies to the Collection- Uses the same phase-dependent boundary as
refetch(). During a mutation handler, the promise can resolve at the fetch boundary before the result applies and clears the error.
- Uses the same phase-dependent boundary as
Direct Writes
Direct writes are intended for scenarios where the normal query/mutation flow doesn't fit your needs. They write directly to the synced data store and bypass the optimistic update system. Their Query cache behavior depends on the collection's sync mode.
Understanding the Data Stores
Query Collections combine three layers:
- TanStack Query cache - The response document for each exact Query key. A successful
queryFnfetch updates this cache, and Collections that acquire the Query project its rows. - Synced Collection rows - Rows projected from acquired Query responses, together with accepted direct writes. A direct write can remain visible here when its response envelope cannot be patched in the Query cache.
- Optimistic mutations store - Temporary changes applied before a mutation handler settles.
The Query cache is the source of truth for Query responses. A Collection also tracks which subsets own its fetched rows and can hold direct writes that have not been confirmed by a later fetch. See Direct Writes and Query Sync for the rules that order these sources.
Normal collection operations (insert, update, delete) create optimistic mutations that are:
- Applied immediately to the UI
- Sent to the server via persistence handlers
- Rolled back automatically if the server request fails
- Replaced with server data when the query refetches
Direct writes bypass this system entirely and write directly to the synced data store, making them useful for handling real-time updates from alternative sources. They do not automatically refetch Query results. The caller is responsible for deciding when a scoped result needs fresh server authority.
When to Use Direct Writes
Direct writes should be used when:
- You need to sync real-time updates from WebSockets or server-sent events
- You're dealing with large datasets where refetching everything is too expensive
- You receive incremental updates or server-computed field updates
- You need to implement complex pagination or partial data loading scenarios
Individual Write Operations
// Insert a new item directly to the synced data store
todosCollection.utils.writeInsert({
id: "1",
text: "Buy milk",
completed: false,
})
// Update an existing item in the synced data store
todosCollection.utils.writeUpdate({ id: "1", completed: true })
// Delete an item from the synced data store
todosCollection.utils.writeDelete("1")
// Upsert (insert or update) in the synced data store
todosCollection.utils.writeUpsert({
id: "1",
text: "Buy milk",
completed: false,
})These operations:
- Write directly to the synced data store
- Do NOT create optimistic mutations
- Return a promise that resolves when the sync commit is accepted, including any configured persistence write. Await it in a mutation handler before returning so the server response is stored before the optimistic state drops.
- While a mutation handler is running, wait behind it and become visible when its optimistic transaction settles, in the same update that drops its optimistic state
- In eager mode, update representable full-result TanStack Query cache rows in place without refetching; Collections sharing the exact Query project those edits
- In on-demand mode, patch changed keys already present in active raw or directly selected Query cache arrays when no other Collection owns that Query. Remove inactive matching-prefix entries when no other Collection or observer retains them, without refetching
The promise does not wait for a separately requested Query refetch. If a handler returns before it writes the server response, its optimistic state drops and the row shows the previous server value until the response arrives.
An explicit utils.refetch() after a direct write may cancel an older request for the same Query key. Collection refetch callers follow the replacement, but a caller using queryClient.fetchQuery() on that key can receive Query Core's CancelledError. Avoid sharing exact Query keys with callers that require their original request to settle normally.
After Collection cleanup starts, direct writes fail with SyncNotInitializedError until a new sync run starts.
Batch Operations
The writeBatch method allows you to perform multiple operations atomically. Any write operations called within the callback will be collected and executed as a single transaction:
The batch includes writes to this Collection only. A synchronous callback may also write to another Collection, but that write has its own completion promise.
todosCollection.utils.writeBatch(() => {
todosCollection.utils.writeInsert({ id: "1", text: "Buy milk" })
todosCollection.utils.writeInsert({ id: "2", text: "Walk dog" })
todosCollection.utils.writeUpdate({ id: "3", completed: true })
todosCollection.utils.writeDelete("4")
})Await writeBatch(...) to wait for the whole sync commit. The callback must be synchronous; the promises returned by writes inside it settle with the batch.
Real-World Example: WebSocket Integration
This example assumes the collection stays loaded for the app's lifetime, so the listener can live at module scope.
// Apply real-time changes from a WebSocket as direct writes
ws.on("todos:update", (changes) => {
todosCollection.utils.writeBatch(() => {
changes.forEach((change) => {
switch (change.type) {
case "insert":
todosCollection.utils.writeInsert(change.data)
break
case "update":
todosCollection.utils.writeUpdate(change.data)
break
case "delete":
todosCollection.utils.writeDelete(change.id)
break
}
})
})
})- Direct writes do not refetch. In eager mode, they patch a representable full-result cache, which other Collections sharing the Query project. In on-demand mode, they patch rows already present in active scoped caches. See Direct Writes and Query Sync.
- A direct write is validated against the synced store after every earlier
sync commit is accepted.
writeUpdateandwriteDeletereject their promise if the row is not in the synced store, andwriteInsertrejects if the row is already there. They do not throw. An event can arrive before the first load finishes, or for a row the collection has not loaded. Handle the rejection, or usewriteUpsertwhen events carry the full row. - In a persisted collection, a direct write can wait for an earlier sync commit's durable write, so its rows can appear after the call returns. Await the returned promise before you read them.
A direct write returns a promise. Await it to know that the write applied, and catch its rejection to handle a validation error:
try {
await todosCollection.utils.writeUpdate({ id: change.id, done: true })
} catch (error) {
if (error instanceof UpdateOperationItemNotFoundError) {
// The row is not in the synced store yet. Refetch, or skip this event.
await todosCollection.utils.refetch()
} else {
throw error
}
}
Import UpdateOperationItemNotFoundError, DeleteOperationItemNotFoundError
and DuplicateKeyInBatchError from @tanstack/query-db-collection, and
DuplicateKeySyncError from @tanstack/db.
Example: Incremental Updates
When the server returns computed fields (like server-generated IDs or timestamps), you can use direct writes to sync the server response without triggering a full refetch:
const todosCollection = createCollection(
queryCollectionOptions({
queryKey: ["todos"],
queryFn: fetchTodos,
queryClient,
getKey: (item) => item.id,
onInsert: async ({ transaction, collection }) => {
const newItems = transaction.mutations.map((m) => m.modified)
// Send to server and get back items with server-computed fields
const serverItems = await api.createTodos(newItems)
// Sync server-computed fields (like server-generated IDs, timestamps, etc.)
// to the collection's synced data store using direct writes
await collection.utils.writeBatch(() => {
serverItems.forEach((serverItem) => {
collection.utils.writeInsert(serverItem)
})
})
// No need to refetch - we've already synced the server response via direct writes
// (optimistic state is automatically replaced when handler completes)
return { refetch: false } // Transitional pre-1.0 opt-out
},
onUpdate: async ({ transaction, collection }) => {
const updates = transaction.mutations.map((m) => ({
id: m.key,
changes: m.changes,
}))
const serverItems = await api.updateTodos(updates)
// Sync server-computed fields from the update response
await collection.utils.writeBatch(() => {
serverItems.forEach((serverItem) => {
collection.utils.writeUpdate(serverItem)
})
})
// No refetch needed since we used direct writes
return { refetch: false } // Transitional pre-1.0 opt-out
},
})
)
// Usage is just like a regular collection
todosCollection.insert({ text: "Buy milk", completed: false })
Server pagination with live queries
useLiveInfiniteQuery in React, Vue, and Svelte grows a local ordered query
window. It does not run TanStack Query's InfiniteQueryObserver. Query
Collections use QueryObserver, so queryFn receives
meta.loadSubsetOptions, not pageParam.
The previously ignored getNextPageParam option has been removed. Delete it
from your hook config; passing it at runtime now throws a clear error.
initialPageParam labels result pages only. It does not set a remote offset
or server cursor.
For server loading, use syncMode: 'on-demand' and make queryFn fulfill the
requested filter, order, offset, and limit. Use a deterministic total order
(for example, a timestamp followed by a unique ID). The loader may request a
prefix, a suffix, a tie group, or the full filtered source. A request is not
necessarily one UI page: the hook fetches an extra row to determine
hasNextPage. Returning one capped endpoint page can incorrectly make the
query appear exhausted even when the server has more rows.
Endpoints with fixed-size pages
If your endpoint uses page numbers, drain enough server pages to fulfill each
request. This example assumes a zero-based page API with a fixed size of 50.
The endpoint must apply the supplied filters and sorts before pagination,
keep a consistent ordered result while its pages are read, and return
nextPage: null only when it has authoritatively exhausted that result.
This example uses offset-based pagination. api.listPosts translates the full
where expression and orderBy options into the endpoint's syntax, and rejects
unsupported expressions. The separate cursor hints are deliberately unused;
cursor-based adapters must handle those hints alongside where, not treat them
as already included in it. See QueryFn and Predicate Push-Down
for translation helpers. Do not drop predicates or filter after paginating:
either changes the requested window.
import { createCollection } from '@tanstack/db'
import { queryCollectionOptions } from '@tanstack/query-db-collection'
type Post = { id: number; createdAt: number; title: string }
const serverPageSize = 50
const postsCollection = createCollection(
queryCollectionOptions({
queryKey: ['posts'],
queryClient,
syncMode: 'on-demand',
getKey: (post: Post) => post.id,
queryFn: async (ctx): Promise<Array<Post>> => {
const { where, orderBy, offset = 0, limit } = ctx.meta?.loadSubsetOptions ?? {}
const skip = offset % serverPageSize
let page: number | null = Math.floor(offset / serverPageSize)
const gathered: Array<Post> = []
while (page !== null && (limit === undefined || gathered.length < skip + limit)) {
ctx.signal.throwIfAborted()
const response: { rows: Array<Post>; nextPage: number | null } =
await api.listPosts({
page,
pageSize: serverPageSize,
where,
orderBy,
signal: ctx.signal,
})
gathered.push(...response.rows)
page = response.nextPage
}
return gathered.slice(skip, limit === undefined ? undefined : skip + limit)
},
}),
)
// React example; the collection protocol is the same for Vue and Svelte.
const { data, fetchNextPage, hasNextPage } = useLiveInfiniteQuery(
(q) => q.from({ post: postsCollection })
.orderBy(({ post }) => post.createdAt)
.orderBy(({ post }) => post.id),
{ pageSize: 20 },
)
Reject failed requests instead of returning partial rows as success. An
unlimited request must drain until the endpoint reports exhaustion. For an
endpoint with opaque cursors, use createCursorPager as shown below. The hook
does not maintain remote cursor history on its own.
Manually appending rows with writeUpsert is a separate, lower-level loading
strategy. It does not make an eager queryFn incremental: a later successful
refetch still replaces its complete state and can remove appended rows.
staleTime: Infinity does not prevent explicit refetch or invalidation.
Endpoints with opaque cursors
Query Collections ask for arbitrary windows, such as rows 40–59. An opaque-cursor API cannot jump to row 40. It can only fetch the first page, then follow the continuation token returned with each page.
createCursorPager bridges those two protocols. It stores complete backend
pages in your existing QueryClient and follows their cursors until it has enough
rows for the requested offset and limit. A later request can reuse that
fresh run of pages and fetch only the missing suffix. The hook still requests
an extra row to decide hasNextPage; the pager just fulfills the resulting
window.
import { createCollection } from '@tanstack/db'
import { createCursorPager, queryCollectionOptions } from '@tanstack/query-db-collection'
type Post = { id: number; createdAt: number; title: string }
const postsCollection = createCollection(
queryCollectionOptions({
queryClient,
queryKey: ['posts', 'rows'],
syncMode: 'on-demand',
getKey: (post: Post) => post.id,
queryFn: async (ctx) => {
const { where, orderBy, offset = 0, limit } = ctx.meta?.loadSubsetOptions ?? {}
// Translate every filter and sort, or reject unsupported expressions.
// This stable, serializable value identifies one ordered result sequence.
const request = api.translatePostQuery({ where, orderBy })
const pager = createCursorPager<Post>({
queryClient,
queryKey: ['posts', 'cursor-pages', request],
staleTime: 60_000,
gcTime: 5 * 60_000,
fetchPage: async (cursor, signal) => {
const response = await api.listPosts({ ...request, cursor, signal })
// Only null means that the source is exhausted.
return { rows: response.items, nextCursor: response.nextCursor ?? null }
},
})
return pager.read({ offset, limit }, ctx.signal)
},
}),
)
Give each result sequence its own key
The pager's query key identifies one ordered backend result, not one requested
window. Leave offset and limit out of the key so wider reads can extend the
same cached prefix. Include everything that changes the result—such as the
tenant, source, filters, and order.
Keep row data and cursor pages under sibling keys:
['posts', 'rows'] QueryCollection row array
['posts', 'cursor-pages', request] backend pages and cursorsThey cannot share a key because the two cache entries have different shapes.
Do not put cursor pages beneath the row key either: manual collection writes
target entries under the row prefix. A shared resource prefix such as
['posts'] still lets you cancel or invalidate both kinds of data together.
The example creates a small pager object for each queryFn call. Those objects
still share cached pages and in-flight fetches through QueryClient. Each pager
serializes its own reads. Retain one pager per result sequence only if calls
must also share that queue or you need to call pager.reset() later.
What happens when the collection asks for rows
For fresh data, the pager uses every cached page it can and fetches only the
missing suffix. nextCursor: null is the sole exhaustion signal: a short or
empty page with another cursor does not end the sequence. An omitted limit
drains the source; a zero limit performs no fetch. Repeated continuation tokens
throw instead of looping forever.
Query owns freshness, garbage collection, retries, and invalidation. Pass
staleTime and gcTime to the pager or let it inherit QueryClient defaults.
Configure retries through QueryClient defaults for the page-key prefix;
imperative Query fetches do not retry by default. The pager keeps every page in
its original form, so Query defaults for maxPages and select do not apply.
When cached pages are stale, the next read refetches every page loaded so far,
starting with the first. It does this even for a shallow window because old
continuation tokens belong to the old sequence. Staleness does not start a
background timer. The page query has no lasting observer, so its gcTime may
expire while the collection's published rows remain visible.
A failed read rejects rather than returning a partial window. Query keeps the
last successful pages, so a later read can retry. Treat their rows as immutable,
just like other Query data. read() returns references from the cached pages,
not detached copies, and does not promise the same array or object identity
across calls.
Refresh or cancel the sequence
After a mutation—or whenever you need a forced refresh—cancel the shared resource prefix before invalidating it:
await queryClient.cancelQueries({ queryKey: ['posts'] })
await queryClient.invalidateQueries({ queryKey: ['posts'] })Cancelling first stops an old page append from completing after invalidation and
marking the old sequence fresh again. It rejects readers waiting on that fetch
with an AbortError and prevents a late result from entering the cache. The
backend must honor the signal if you also want to stop its network work.
Aborting the signal passed to read() rejects only that reader; it does not
cancel a page fetch shared with another reader. A cancelling Query refetch moves
waiting readers to the replacement fetch. collection.utils.refetch() refreshes
the row queries but may reuse fresh cursor pages. pager.reset() removes one
pager's exact page key and cancels its queued reads, but does not refresh the
collection's row query.
Backend contract
The helper handles cursor traversal only. Your adapter must:
- translate every requested
whereandorderBy, or reject unsupported expressions; - apply filters and ordering before pagination;
- use a deterministic total order, including a unique tie-breaker;
- keep that ordered result consistent while its cursor sequence is read; and
- return
nextCursor: nullonly when the result is authoritatively exhausted.
LoadSubsetOptions.cursor contains query-expression hints, not the backend's
opaque token. createCursorPager therefore addresses the cursor sequence by
offset and deliberately does not consume those hints. No client-side pager can
make a stable snapshot from an endpoint whose ordering changes between pages;
the endpoint must define its own consistency and cursor-expiry rules.
Important Behaviors
Full State Sync
The query collection treats the queryFn result as the complete state of the collection. This means:
- Items present in the collection but not in the query result will be deleted
- Items in the query result but not in the collection will be inserted
- Items present in both will be updated if they differ
This is important when the same entity type can be loaded from multiple REST
endpoints. For example, do not point the same Query Collection at
/api/documents/preview for one load and /api/documents/deleted for another
unless each result represents the complete state for that collection
scope. A narrower endpoint can otherwise remove rows that were loaded from a
different endpoint.
For multiple endpoint or subset-loading use cases, choose the pattern that matches your API semantics:
- Use
syncMode: 'on-demand'when one logical collection can serve different subsets of data. In this mode, query predicates (where,orderBy,limit, andoffset) are passed to yourqueryFnviactx.meta.loadSubsetOptions, letting you translate them into API parameters. - Use separate Query Collections when endpoints represent distinct server scopes
whose results should not replace each other. Use
unionAllto combine them into a single query when you need a unified view across endpoints. - Use direct writes such as
writeUpsert/writeBatchfor lower-level incremental loading when you intentionally want to merge server responses into the synced store yourself.
Empty Array Behavior
When queryFn returns an empty array, all items in the collection will be deleted. This is because the collection interprets an empty array as "the server has no items".
// This will delete all items in the collection
queryFn: async () => []
Handling Partial/Incremental Fetches
Since the query collection expects queryFn to return the complete state, you can handle partial fetches by merging new data with existing data:
const todosCollection = createCollection(
queryCollectionOptions({
queryKey: ["todos"],
queryFn: async ({ queryKey }) => {
// Get existing data from cache
const existingData = queryClient.getQueryData(queryKey) || []
// Fetch only new/updated items (e.g., changes since last sync)
const lastSyncTime = localStorage.getItem("todos-last-sync")
const newData = await fetch(`/api/todos?since=${lastSyncTime}`).then(
(r) => r.json()
)
// Merge new data with existing data
const existingMap = new Map(existingData.map((item) => [item.id, item]))
// Apply updates and additions
newData.forEach((item) => {
existingMap.set(item.id, item)
})
// Handle deletions if your API provides them
if (newData.deletions) {
newData.deletions.forEach((id) => existingMap.delete(id))
}
// Update sync time
localStorage.setItem("todos-last-sync", new Date().toISOString())
// Return the complete merged state
return Array.from(existingMap.values())
},
queryClient,
getKey: (item) => item.id,
})
)
This pattern allows you to:
- Fetch only incremental changes from your API
- Merge those changes with existing data
- Return the complete state that the collection expects
- Avoid the performance overhead of fetching all data every time
Direct Writes and Query Sync
Direct writes update the Collection when their sync commit is accepted, or when a running mutation handler settles. They do not start a Query refetch. The following boundaries determine what the Query cache and other Collections see:
| Event | Query cache | Collection rows |
|---|---|---|
Successful queryFn fetch | Stores the response at its exact Query key. | Collections that acquired that Query project its rows and reconcile subset ownership. |
| Accepted eager direct write | When the full-result response is representable, patches its changed keys and preserves unrelated rows. | The writer receives the edit. Compatible eager Collections sharing that Query, and on-demand peers that already acquired it, project the cache patch. |
| Accepted on-demand direct write | When no other Collection owns the Query, patches changed keys already present in eligible active raw arrays or arrays exposed directly by select. Derived projections stay unchanged. | The writer accepts the edit without inferring new subset membership or starting a request. An unacquired peer cannot treat the local cache edit alone as a new server result. |
Each on-demand Query cache entry may represent a different predicate, order, limit, or offset. Direct writes remove inactive entries under the Collection's query-key prefix when no other Collection or observer retains them; unrelated entries outside that prefix remain. A direct write cannot infer whether an inserted row belongs in a scoped result or which row replaces a deleted item in a limited window. A new direct insert has no inferred subset owner and survives subset unload.
Fetch order applies per key. A fetch started before an accepted direct write
cannot undo that write's claimed keys; a fetch started later may reconcile or
replace them. While a row remains continuously owned by at least one loaded
subset, if overlapping subset fetches both return that key, the later-started
successful fetch controls its Collection value even when the older request
settles last. Once all owners release the row, reacquiring a subset projects
that Query's retained cache entry under the usual staleTime and gcTime
rules. A cache patch is not a successful fetch: it does not clear a recorded
Query error, pending invalidation, or staleness.
A later successful complete, unbounded response for a matching scope can
claim an ownerless direct row or remove it when omitted. A limited, offset,
cursor, failed, or pre-write response cannot prove that omission. The absence
claim belongs to that scope; it does not invalidate another subset's Query
cache entry. For example, suppose on-demand scope A has loaded an empty result,
and scope B overlaps A. Both scopes match row x:
writeInsert(x)adds an ownerless row. B starts a fetch that will returnx.- An explicit refetch of A starts later and returns a complete empty result. A removes
x. - B's earlier-started fetch then returns
x. B can projectxagain because A's empty response did not establish global absence across Query entries.
This is a lower-level API. Use collection.utils.refetch() to request current
server rows for tracked scopes, but do not treat one scope's result as a global
snapshot. If an older request for the same Query is still in flight, that
explicit refetch starts a new request.
Deleting a fetched row ends its existing subset ownership. Inserting or upserting the same key afterward creates a new direct row without inheriting those old subset claims; it can survive their unload until a later Query result claims it or you delete it explicitly.
An eager Collection that subscribes later can project a representable cached
edit without starting a request, even with enabled: false.
An on-demand Collection that has not acquired its scope may instead
revalidate on acquisition; another Collection's local cache edit alone has no
server-result authority for that scope. An unrelated later direct write does
not change fetch order for an earlier written key.
Dehydrating a patched Query cache does not carry its in-memory direct-write provenance into another QueryClient. A Collection loading that hydrated cache can see the local edit as cached data. Refetch after hydration when the new client needs a server-confirmed result.
A fresh persisted Collection instance cannot recover the order of a stored direct write and a retained Query cache row with the same key, whether the row has a subset owner or not. It refetches that Query when the subset is acquired, then applies the new server result. The earlier direct write still starts no request by itself.
To use direct writes with persistence handlers, return { refetch: false } to avoid the handler's separate automatic refetch during the pre-1.0 transition. An on-demand queryFn still needs to return the complete current result for its pushed-down predicate, order, limit, and offset when it does run. For robust real-time synchronization, use a collection backed by a sync engine that owns snapshot and change-stream ordering.
Complete Direct Write API Reference
All direct write methods are available on collection.utils:
writeInsert(data): Insert one or more items directlywriteUpdate(data): Update one or more items directlywriteDelete(keys): Delete one or more items directlywriteUpsert(data): Insert or update one or more items directlywriteBatch(callback): Perform multiple operations atomicallyrefetch(opts?): Trigger a refetch of the query
QueryFn and Predicate Push-Down
When using syncMode: 'on-demand', the collection automatically pushes down query predicates (where clauses, orderBy, limit, and offset) to your queryFn. This allows you to fetch only the data needed for each specific query, rather than fetching the entire dataset.
How LoadSubsetOptions Are Passed
LoadSubsetOptions are passed to your queryFn via the query context's meta property:
queryFn: async (ctx) => {
// Extract LoadSubsetOptions from the context
const { limit, offset, where, orderBy } = ctx.meta.loadSubsetOptions
// Use these to fetch only the data you need
// - where: filter expression (AST)
// - orderBy: sort expression (AST)
// - limit: maximum number of rows
// - offset: number of rows to skip (for pagination)
// ...
}
The where and orderBy fields are expression trees (AST - Abstract Syntax Tree) that need to be parsed. TanStack DB provides helper functions to make this easy.
Expression Helpers
import {
parseWhereExpression,
parseOrderByExpression,
extractSimpleComparisons,
parseLoadSubsetOptions,
} from '@tanstack/db'
// Or from '@tanstack/query-db-collection' (re-exported for convenience)
These helpers allow you to parse expression trees without manually traversing complex AST structures.
Quick Start: Simple REST API
import { createCollection } from '@tanstack/react-db'
import { queryCollectionOptions } from '@tanstack/query-db-collection'
import { parseLoadSubsetOptions } from '@tanstack/db'
import { QueryClient } from '@tanstack/query-core'
const queryClient = new QueryClient()
const productsCollection = createCollection(
queryCollectionOptions({
id: 'products',
queryKey: ['products'],
queryClient,
getKey: (item) => item.id,
syncMode: 'on-demand', // Enable predicate push-down
queryFn: async (ctx) => {
const { limit, offset, where, orderBy } = ctx.meta.loadSubsetOptions
// Parse the expressions into simple format
const parsed = parseLoadSubsetOptions({ where, orderBy, limit })
// Build query parameters from parsed filters
const params = new URLSearchParams()
// Add filters
parsed.filters.forEach(({ field, operator, value }) => {
const fieldName = field.join('.')
if (operator === 'eq') {
params.set(fieldName, String(value))
} else if (operator === 'lt') {
params.set(`${fieldName}_lt`, String(value))
} else if (operator === 'gt') {
params.set(`${fieldName}_gt`, String(value))
}
})
// Add sorting
if (parsed.sorts.length > 0) {
const sortParam = parsed.sorts
.map(s => `${s.field.join('.')}:${s.direction}`)
.join(',')
params.set('sort', sortParam)
}
// Add limit
if (parsed.limit) {
params.set('limit', String(parsed.limit))
}
// Add offset for pagination
if (offset) {
params.set('offset', String(offset))
}
const response = await fetch(`/api/products?${params}`)
return response.json()
},
})
)
// Usage with live queries
import { createLiveQueryCollection } from '@tanstack/react-db'
import { eq, lt, and } from '@tanstack/db'
const affordableElectronics = createLiveQueryCollection({
query: (q) =>
q.from({ product: productsCollection })
.where(({ product }) => and(
eq(product.category, 'electronics'),
lt(product.price, 100)
))
.orderBy(({ product }) => product.price, 'asc')
.limit(10)
.select(({ product }) => product)
})
// This triggers a queryFn call with:
// GET /api/products?category=electronics&price_lt=100&sort=price:asc&limit=10
// When paginating, offset is included: &offset=20
Custom Handlers for Complex APIs
For APIs with specific formats, use custom handlers:
queryFn: async (ctx) => {
const { where, orderBy, limit } = ctx.meta.loadSubsetOptions
// Use custom handlers to match your API's format
const filters = parseWhereExpression(where, {
handlers: {
eq: (field, value) => ({
field: field.join('.'),
op: 'equals',
value
}),
lt: (field, value) => ({
field: field.join('.'),
op: 'lessThan',
value
}),
and: (...conditions) => ({
operator: 'AND',
conditions
}),
or: (...conditions) => ({
operator: 'OR',
conditions
}),
}
})
const sorts = parseOrderByExpression(orderBy)
return api.query({
filters,
sort: sorts.map(s => ({
field: s.field.join('.'),
order: s.direction.toUpperCase()
})),
limit
})
}
GraphQL Example
queryFn: async (ctx) => {
const { where, orderBy, limit } = ctx.meta.loadSubsetOptions
// Convert to a GraphQL where clause format
const whereClause = parseWhereExpression(where, {
handlers: {
eq: (field, value) => ({
[field.join('_')]: { _eq: value }
}),
lt: (field, value) => ({
[field.join('_')]: { _lt: value }
}),
and: (...conditions) => ({ _and: conditions }),
or: (...conditions) => ({ _or: conditions }),
}
})
// Convert to a GraphQL order_by format
const sorts = parseOrderByExpression(orderBy)
const orderByClause = sorts.map(s => ({
[s.field.join('_')]: s.direction
}))
const { data } = await graphqlClient.query({
query: gql`
query GetProducts($where: product_bool_exp, $orderBy: [product_order_by!], $limit: Int) {
product(where: $where, order_by: $orderBy, limit: $limit) {
id
name
category
price
}
}
`,
variables: {
where: whereClause,
orderBy: orderByClause,
limit
}
})
return data.product
}
Expression Helper API Reference
parseLoadSubsetOptions(options)
Convenience function that parses all LoadSubsetOptions at once. Good for simple use cases.
const { filters, sorts, limit, offset } = parseLoadSubsetOptions(ctx.meta?.loadSubsetOptions)
// filters: [{ field: ['category'], operator: 'eq', value: 'electronics' }]
// sorts: [{ field: ['price'], direction: 'asc', nulls: 'last' }]
// limit: 10
// offset: 20 (for pagination)
parseWhereExpression(expr, options)
Parses a WHERE expression using custom handlers for each operator. Use this for complete control over the output format.
const filters = parseWhereExpression(where, {
handlers: {
eq: (field, value) => ({ [field.join('.')]: value }),
lt: (field, value) => ({ [`${field.join('.')}_lt`]: value }),
and: (...filters) => Object.assign({}, ...filters)
},
onUnknownOperator: (operator, args) => {
console.warn(`Unsupported operator: ${operator}`)
return null
}
})
parseOrderByExpression(orderBy)
Parses an ORDER BY expression into a simple array.
const sorts = parseOrderByExpression(orderBy)
// Returns: [{ field: ['price'], direction: 'asc', nulls: 'last' }]
extractSimpleComparisons(expr)
Extracts simple AND-ed comparisons from a WHERE expression. Note: Only works for simple AND conditions.
const comparisons = extractSimpleComparisons(where)
// Returns: [
// { field: ['category'], operator: 'eq', value: 'electronics' },
// { field: ['price'], operator: 'lt', value: 100 }
// ]
Supported Operators
eq- Equality (=)gt- Greater than (>)gte- Greater than or equal (>=)lt- Less than (<)lte- Less than or equal (<=)and- Logical ANDor- Logical ORin- IN clause
Using Query Key Builders
With syncMode: 'on-demand', a static key such as queryKey: ['products']
automatically includes the subset demand identity. Filters, ordering, limits,
offsets, and cursor hints distinguish cache entries when they change the
requested data. Prefer a static key when you do not need a custom key structure.
A function-based queryKey replaces that automatic key construction. Include
all options that change the requested data; omitting offset, for example,
can make different windows share a cache entry. Use getLoadSubsetDemandKey
from @tanstack/db to retain the same subset identity as a static key, including
limit: 0:
import { getLoadSubsetDemandKey } from '@tanstack/db'
const productsCollection = createCollection(
queryCollectionOptions({
id: 'products',
queryKey: (opts) => {
const demandKey = getLoadSubsetDemandKey(opts)
return demandKey === undefined ? ['products'] : ['products', demandKey]
},
queryClient,
getKey: (item) => item.id,
syncMode: 'on-demand',
queryFn: async (ctx) => { /* ... */ },
})
)
Query Key Prefix Convention
When using a function-based queryKey, all derived keys must extend the base key as a prefix. The base key is what your function returns when called with no options (queryKey({})).
TanStack Query uses prefix matching for cache operations internally. The query collection relies on this to find all cache entries belonging to a collection — including stale entries from destroyed query observers that are still held in cache due to gcTime. If derived keys don't share the base prefix, cache updates may silently miss entries, leading to stale data.
The example above returns ['products'] for queryKey({}) and keeps it as
the prefix of every derived key. Avoid changing the prefix for subset demands:
// ❌ Wrong: base key ['products-all'] is NOT a prefix of ['products-filtered', ...]
queryKey: (opts) => {
const demandKey = getLoadSubsetDemandKey(opts)
return demandKey === undefined
? ['products-all']
: ['products-filtered', demandKey]
}
Tips
- Start with
parseLoadSubsetOptionsfor simple use cases - Use custom handlers via
parseWhereExpressionfor APIs with specific formats - Handle unsupported operators with the
onUnknownOperatorcallback - Log parsed results during development to verify correctness