parseLoadSubsetOptions(options)parseWhereExpression(expr, options)parseOrderByExpression(orderBy)extractSimpleComparisons(expr)Query collections provide seamless integration between TanStack DB and TanStack Query, enabling automatic synchronization between your local database and remote data sources.
The @tanstack/query-db-collection package allows you to create collections that:
npm install @tanstack/query-db-collection @tanstack/query-core @tanstack/db npm install @tanstack/query-db-collection @tanstack/query-core @tanstack/db import { QueryClient } from "@tanstack/query-core" import { createCollection } from "@tanstack/db" import { queryCollectionOptions } from "@tanstack/query-db-collection" const queryClient = new QueryClient() const todosCollection = createCollection( queryCollectionOptions({ queryKey: ["todos"], queryFn: async () => { const response = await fetch("/api/todos") return response.json() }, queryClient, getKey: (item) => item.id, }) ) import { QueryClient } from "@tanstack/query-core" import { createCollection } from "@tanstack/db" import { queryCollectionOptions } from "@tanstack/query-db-collection" const queryClient = new QueryClient() const todosCollection = createCollection( queryCollectionOptions({ queryKey: ["todos"], queryFn: async () => { const response = await fetch("/api/todos") return response.json() }, queryClient, getKey: (item) => item.id, }) ) The queryCollectionOptions function accepts the following options:
You can define handlers that are called when mutations occur. These handlers can persist changes to your backend and control whether the query should refetch after the operation:
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) // Returning nothing or { refetch: true } will trigger a refetch // Return { refetch: false } to skip automatic refetch }, 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) }, }) ) 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) // Returning nothing or { refetch: true } will trigger a refetch // Return { refetch: false } to skip automatic refetch }, 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) }, }) ) By default, after any persistence handler (onInsert, onUpdate, or onDelete) completes successfully, the query will automatically refetch to ensure the local state matches the server state.
You can control this behavior by returning an object with a refetch property:
onInsert: async ({ transaction }) => { await api.createTodos(transaction.mutations.map((m) => m.modified)) // Skip the automatic refetch return { refetch: false } } onInsert: async ({ transaction }) => { await api.createTodos(transaction.mutations.map((m) => m.modified)) // Skip the automatic refetch return { refetch: false } } This is useful when:
The collection provides these utility methods via collection.utils:
Direct writes are intended for scenarios where the normal query/mutation flow doesn't fit your needs. They allow you to write directly to the synced data store, bypassing the optimistic update system and query refetch mechanism.
Query Collections maintain two data stores:
Normal collection operations (insert, update, delete) create optimistic mutations that are:
Direct writes bypass this system entirely and write directly to the synced data store, making them ideal for handling real-time updates from alternative sources.
Direct writes should be used when:
// 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, }) // 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:
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:
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") }) 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") }) // Handle real-time updates from WebSocket without triggering full refetches 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 } }) }) }) // Handle real-time updates from WebSocket without triggering full refetches 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 } }) }) }) When the server returns computed fields (like server-generated IDs or timestamps), you can use the onInsert handler with { refetch: false } to avoid unnecessary refetches while still syncing the server response:
const todosCollection = createCollection( queryCollectionOptions({ queryKey: ["todos"], queryFn: fetchTodos, queryClient, getKey: (item) => item.id, onInsert: async ({ transaction }) => { 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 todosCollection.utils.writeBatch(() => { serverItems.forEach((serverItem) => { todosCollection.utils.writeInsert(serverItem) }) }) // Skip automatic refetch since we've already synced the server response // (optimistic state is automatically replaced when handler completes) return { refetch: false } }, onUpdate: async ({ transaction }) => { 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 todosCollection.utils.writeBatch(() => { serverItems.forEach((serverItem) => { todosCollection.utils.writeUpdate(serverItem) }) }) return { refetch: false } }, }) ) // Usage is just like a regular collection todosCollection.insert({ text: "Buy milk", completed: false }) const todosCollection = createCollection( queryCollectionOptions({ queryKey: ["todos"], queryFn: fetchTodos, queryClient, getKey: (item) => item.id, onInsert: async ({ transaction }) => { 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 todosCollection.utils.writeBatch(() => { serverItems.forEach((serverItem) => { todosCollection.utils.writeInsert(serverItem) }) }) // Skip automatic refetch since we've already synced the server response // (optimistic state is automatically replaced when handler completes) return { refetch: false } }, onUpdate: async ({ transaction }) => { 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 todosCollection.utils.writeBatch(() => { serverItems.forEach((serverItem) => { todosCollection.utils.writeUpdate(serverItem) }) }) return { refetch: false } }, }) ) // Usage is just like a regular collection todosCollection.insert({ text: "Buy milk", completed: false }) // Load additional pages without refetching existing data const loadMoreTodos = async (page) => { const newTodos = await api.getTodos({ page, limit: 50 }) // Add new items without affecting existing ones todosCollection.utils.writeBatch(() => { newTodos.forEach((todo) => { todosCollection.utils.writeInsert(todo) }) }) } // Load additional pages without refetching existing data const loadMoreTodos = async (page) => { const newTodos = await api.getTodos({ page, limit: 50 }) // Add new items without affecting existing ones todosCollection.utils.writeBatch(() => { newTodos.forEach((todo) => { todosCollection.utils.writeInsert(todo) }) }) } The query collection treats the queryFn result as the complete state of the collection. This means:
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 () => [] // This will delete all items in the collection queryFn: async () => [] 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, }) ) 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:
Direct writes update the collection immediately and also update the TanStack Query cache. However, they do not prevent the normal query sync behavior. If your queryFn returns data that conflicts with your direct writes, the query data will take precedence.
To handle this properly:
All direct write methods are available on collection.utils:
When using syncMode: 'on-demand', the collection automatically pushes down query predicates (where clauses, orderBy, and limit) to your queryFn. This allows you to fetch only the data needed for each specific query, rather than fetching the entire dataset.
LoadSubsetOptions are passed to your queryFn via the query context's meta property:
queryFn: async (ctx) => { // Extract LoadSubsetOptions from the context const { limit, where, orderBy } = ctx.meta.loadSubsetOptions // Use these to fetch only the data you need // ... } queryFn: async (ctx) => { // Extract LoadSubsetOptions from the context const { limit, where, orderBy } = ctx.meta.loadSubsetOptions // Use these to fetch only the data you need // ... } 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.
import { parseWhereExpression, parseOrderByExpression, extractSimpleComparisons, parseLoadSubsetOptions, } from '@tanstack/db' // Or from '@tanstack/query-db-collection' (re-exported for convenience) 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.
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, 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)) } 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 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, 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)) } 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 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 }) } 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 }) } 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 } 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 } Convenience function that parses all LoadSubsetOptions at once. Good for simple use cases.
const { filters, sorts, limit } = parseLoadSubsetOptions(ctx.meta?.loadSubsetOptions) // filters: [{ field: ['category'], operator: 'eq', value: 'electronics' }] // sorts: [{ field: ['price'], direction: 'asc', nulls: 'last' }] // limit: 10 const { filters, sorts, limit } = parseLoadSubsetOptions(ctx.meta?.loadSubsetOptions) // filters: [{ field: ['category'], operator: 'eq', value: 'electronics' }] // sorts: [{ field: ['price'], direction: 'asc', nulls: 'last' }] // limit: 10 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 } }) 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 } }) Parses an ORDER BY expression into a simple array.
const sorts = parseOrderByExpression(orderBy) // Returns: [{ field: ['price'], direction: 'asc', nulls: 'last' }] const sorts = parseOrderByExpression(orderBy) // Returns: [{ field: ['price'], direction: 'asc', nulls: 'last' }] 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 } // ] const comparisons = extractSimpleComparisons(where) // Returns: [ // { field: ['category'], operator: 'eq', value: 'electronics' }, // { field: ['price'], operator: 'lt', value: 100 } // ] Create different cache entries for different filter combinations:
const productsCollection = createCollection( queryCollectionOptions({ id: 'products', // Dynamic query key based on filters queryKey: (opts) => { const parsed = parseLoadSubsetOptions(opts) const cacheKey = ['products'] parsed.filters.forEach(f => { cacheKey.push(`${f.field.join('.')}-${f.operator}-${f.value}`) }) if (parsed.limit) { cacheKey.push(`limit-${parsed.limit}`) } return cacheKey }, queryClient, getKey: (item) => item.id, syncMode: 'on-demand', queryFn: async (ctx) => { /* ... */ }, }) ) const productsCollection = createCollection( queryCollectionOptions({ id: 'products', // Dynamic query key based on filters queryKey: (opts) => { const parsed = parseLoadSubsetOptions(opts) const cacheKey = ['products'] parsed.filters.forEach(f => { cacheKey.push(`${f.field.join('.')}-${f.operator}-${f.value}`) }) if (parsed.limit) { cacheKey.push(`limit-${parsed.limit}`) } return cacheKey }, queryClient, getKey: (item) => item.id, syncMode: 'on-demand', queryFn: async (ctx) => { /* ... */ }, }) ) 