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 an escape hatch 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
}
})
})
})
// Handle server responses after mutations without full refetch
const createTodo = async (todo) => {
// Optimistically add the todo
const tempId = crypto.randomUUID()
todosCollection.insert({ ...todo, id: tempId })
try {
// Send to server
const serverTodo = await api.createTodo(todo)
// Sync the server response (with server-generated ID and timestamps)
// without triggering a full collection refetch
todosCollection.utils.writeBatch(() => {
todosCollection.utils.writeDelete(tempId)
todosCollection.utils.writeInsert(serverTodo)
})
} catch (error) {
// Rollback happens automatically
throw error
}
}
// Handle server responses after mutations without full refetch
const createTodo = async (todo) => {
// Optimistically add the todo
const tempId = crypto.randomUUID()
todosCollection.insert({ ...todo, id: tempId })
try {
// Send to server
const serverTodo = await api.createTodo(todo)
// Sync the server response (with server-generated ID and timestamps)
// without triggering a full collection refetch
todosCollection.utils.writeBatch(() => {
todosCollection.utils.writeDelete(tempId)
todosCollection.utils.writeInsert(serverTodo)
})
} catch (error) {
// Rollback happens automatically
throw error
}
}
// 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: