|
| 1 | +--- |
| 2 | +title: Query Collection |
| 3 | +--- |
| 4 | + |
| 5 | +# Query Collection |
| 6 | + |
| 7 | +Query collections provide seamless integration between TanStack DB and TanStack Query, enabling automatic synchronization between your local database and remote data sources. |
| 8 | + |
| 9 | +## Overview |
| 10 | + |
| 11 | +The `@tanstack/query-db-collection` package allows you to create collections that: |
| 12 | +- Automatically sync with remote data via TanStack Query |
| 13 | +- Support optimistic updates with automatic rollback on errors |
| 14 | +- Handle persistence through customizable mutation handlers |
| 15 | +- Provide direct write capabilities as an escape hatch for advanced scenarios |
| 16 | + |
| 17 | +## Installation |
| 18 | + |
| 19 | +```bash |
| 20 | +npm install @tanstack/query-db-collection @tanstack/query-core @tanstack/db |
| 21 | +``` |
| 22 | + |
| 23 | +## Basic Usage |
| 24 | + |
| 25 | +```typescript |
| 26 | +import { QueryClient } from '@tanstack/query-core' |
| 27 | +import { createCollection } from '@tanstack/db' |
| 28 | +import { queryCollectionOptions } from '@tanstack/query-db-collection' |
| 29 | + |
| 30 | +const queryClient = new QueryClient() |
| 31 | + |
| 32 | +const todosCollection = createCollection( |
| 33 | + queryCollectionOptions({ |
| 34 | + queryKey: ['todos'], |
| 35 | + queryFn: async () => { |
| 36 | + const response = await fetch('/api/todos') |
| 37 | + return response.json() |
| 38 | + }, |
| 39 | + queryClient, |
| 40 | + getKey: (item) => item.id, |
| 41 | + }) |
| 42 | +) |
| 43 | +``` |
| 44 | + |
| 45 | +## Configuration Options |
| 46 | + |
| 47 | +The `queryCollectionOptions` function accepts the following options: |
| 48 | + |
| 49 | +### Required Options |
| 50 | + |
| 51 | +- `queryKey`: The query key for TanStack Query |
| 52 | +- `queryFn`: Function that fetches data from the server |
| 53 | +- `queryClient`: TanStack Query client instance |
| 54 | +- `getKey`: Function to extract the unique key from an item |
| 55 | + |
| 56 | +### Query Options |
| 57 | + |
| 58 | +- `enabled`: Whether the query should automatically run (default: `true`) |
| 59 | +- `refetchInterval`: Refetch interval in milliseconds |
| 60 | +- `retry`: Retry configuration for failed queries |
| 61 | +- `retryDelay`: Delay between retries |
| 62 | +- `staleTime`: How long data is considered fresh |
| 63 | +- `meta`: Optional metadata that will be passed to the query function context |
| 64 | + |
| 65 | +### Collection Options |
| 66 | + |
| 67 | +- `id`: Unique identifier for the collection |
| 68 | +- `schema`: Schema for validating items |
| 69 | +- `sync`: Custom sync configuration |
| 70 | +- `startSync`: Whether to start syncing immediately (default: `true`) |
| 71 | + |
| 72 | +### Persistence Handlers |
| 73 | + |
| 74 | +- `onInsert`: Handler called before insert operations |
| 75 | +- `onUpdate`: Handler called before update operations |
| 76 | +- `onDelete`: Handler called before delete operations |
| 77 | + |
| 78 | +## Persistence Handlers |
| 79 | + |
| 80 | +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: |
| 81 | + |
| 82 | +```typescript |
| 83 | +const todosCollection = createCollection( |
| 84 | + queryCollectionOptions({ |
| 85 | + queryKey: ['todos'], |
| 86 | + queryFn: fetchTodos, |
| 87 | + queryClient, |
| 88 | + getKey: (item) => item.id, |
| 89 | + |
| 90 | + onInsert: async ({ transaction }) => { |
| 91 | + const newItems = transaction.mutations.map(m => m.modified) |
| 92 | + await api.createTodos(newItems) |
| 93 | + // Returning nothing or { refetch: true } will trigger a refetch |
| 94 | + // Return { refetch: false } to skip automatic refetch |
| 95 | + }, |
| 96 | + |
| 97 | + onUpdate: async ({ transaction }) => { |
| 98 | + const updates = transaction.mutations.map(m => ({ |
| 99 | + id: m.key, |
| 100 | + changes: m.changes |
| 101 | + })) |
| 102 | + await api.updateTodos(updates) |
| 103 | + }, |
| 104 | + |
| 105 | + onDelete: async ({ transaction }) => { |
| 106 | + const ids = transaction.mutations.map(m => m.key) |
| 107 | + await api.deleteTodos(ids) |
| 108 | + } |
| 109 | + }) |
| 110 | +) |
| 111 | +``` |
| 112 | + |
| 113 | +### Controlling Refetch Behavior |
| 114 | + |
| 115 | +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. |
| 116 | + |
| 117 | +You can control this behavior by returning an object with a `refetch` property: |
| 118 | + |
| 119 | +```typescript |
| 120 | +onInsert: async ({ transaction }) => { |
| 121 | + await api.createTodos(transaction.mutations.map(m => m.modified)) |
| 122 | + |
| 123 | + // Skip the automatic refetch |
| 124 | + return { refetch: false } |
| 125 | +} |
| 126 | +``` |
| 127 | + |
| 128 | +This is useful when: |
| 129 | +- You're confident the server state matches what you sent |
| 130 | +- You want to avoid unnecessary network requests |
| 131 | +- You're handling state updates through other mechanisms (like WebSockets) |
| 132 | + |
| 133 | +## Utility Methods |
| 134 | + |
| 135 | +The collection provides these utility methods via `collection.utils`: |
| 136 | + |
| 137 | +- `refetch()`: Manually trigger a refetch of the query |
| 138 | + |
| 139 | +## Direct Writes (Advanced) |
| 140 | + |
| 141 | +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. |
| 142 | + |
| 143 | +### Understanding the Data Stores |
| 144 | + |
| 145 | +Query Collections maintain two data stores: |
| 146 | +1. **Synced Data Store** - The authoritative state synchronized with the server via `queryFn` |
| 147 | +2. **Optimistic Mutations Store** - Temporary changes that are applied optimistically before server confirmation |
| 148 | + |
| 149 | +Normal collection operations (insert, update, delete) create optimistic mutations that are: |
| 150 | +- Applied immediately to the UI |
| 151 | +- Sent to the server via persistence handlers |
| 152 | +- Rolled back automatically if the server request fails |
| 153 | +- Replaced with server data when the query refetches |
| 154 | + |
| 155 | +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. |
| 156 | + |
| 157 | +### When to Use Direct Writes |
| 158 | + |
| 159 | +Direct writes should be used when: |
| 160 | +- You need to sync real-time updates from WebSockets or server-sent events |
| 161 | +- You're dealing with large datasets where refetching everything is too expensive |
| 162 | +- You receive incremental updates or server-computed field updates |
| 163 | +- You need to implement complex pagination or partial data loading scenarios |
| 164 | + |
| 165 | +### Individual Write Operations |
| 166 | + |
| 167 | +```typescript |
| 168 | +// Insert a new item directly to the synced data store |
| 169 | +todosCollection.utils.writeInsert({ id: '1', text: 'Buy milk', completed: false }) |
| 170 | + |
| 171 | +// Update an existing item in the synced data store |
| 172 | +todosCollection.utils.writeUpdate({ id: '1', completed: true }) |
| 173 | + |
| 174 | +// Delete an item from the synced data store |
| 175 | +todosCollection.utils.writeDelete('1') |
| 176 | + |
| 177 | +// Upsert (insert or update) in the synced data store |
| 178 | +todosCollection.utils.writeUpsert({ id: '1', text: 'Buy milk', completed: false }) |
| 179 | +``` |
| 180 | + |
| 181 | +These operations: |
| 182 | +- Write directly to the synced data store |
| 183 | +- Do NOT create optimistic mutations |
| 184 | +- Do NOT trigger automatic query refetches |
| 185 | +- Update the TanStack Query cache immediately |
| 186 | +- Are immediately visible in the UI |
| 187 | + |
| 188 | +### Batch Operations |
| 189 | + |
| 190 | +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: |
| 191 | + |
| 192 | +```typescript |
| 193 | +todosCollection.utils.writeBatch(() => { |
| 194 | + todosCollection.utils.writeInsert({ id: '1', text: 'Buy milk' }) |
| 195 | + todosCollection.utils.writeInsert({ id: '2', text: 'Walk dog' }) |
| 196 | + todosCollection.utils.writeUpdate({ id: '3', completed: true }) |
| 197 | + todosCollection.utils.writeDelete('4') |
| 198 | +}) |
| 199 | +``` |
| 200 | + |
| 201 | +### Real-World Example: WebSocket Integration |
| 202 | + |
| 203 | +```typescript |
| 204 | +// Handle real-time updates from WebSocket without triggering full refetches |
| 205 | +ws.on('todos:update', (changes) => { |
| 206 | + todosCollection.utils.writeBatch(() => { |
| 207 | + changes.forEach(change => { |
| 208 | + switch (change.type) { |
| 209 | + case 'insert': |
| 210 | + todosCollection.utils.writeInsert(change.data) |
| 211 | + break |
| 212 | + case 'update': |
| 213 | + todosCollection.utils.writeUpdate(change.data) |
| 214 | + break |
| 215 | + case 'delete': |
| 216 | + todosCollection.utils.writeDelete(change.id) |
| 217 | + break |
| 218 | + } |
| 219 | + }) |
| 220 | + }) |
| 221 | +}) |
| 222 | +``` |
| 223 | + |
| 224 | +### Example: Incremental Updates |
| 225 | + |
| 226 | +```typescript |
| 227 | +// Handle server responses after mutations without full refetch |
| 228 | +const createTodo = async (todo) => { |
| 229 | + // Optimistically add the todo |
| 230 | + const tempId = crypto.randomUUID() |
| 231 | + todosCollection.insert({ ...todo, id: tempId }) |
| 232 | + |
| 233 | + try { |
| 234 | + // Send to server |
| 235 | + const serverTodo = await api.createTodo(todo) |
| 236 | + |
| 237 | + // Sync the server response (with server-generated ID and timestamps) |
| 238 | + // without triggering a full collection refetch |
| 239 | + todosCollection.utils.writeBatch(() => { |
| 240 | + todosCollection.utils.writeDelete(tempId) |
| 241 | + todosCollection.utils.writeInsert(serverTodo) |
| 242 | + }) |
| 243 | + } catch (error) { |
| 244 | + // Rollback happens automatically |
| 245 | + throw error |
| 246 | + } |
| 247 | +} |
| 248 | +``` |
| 249 | + |
| 250 | +### Example: Large Dataset Pagination |
| 251 | + |
| 252 | +```typescript |
| 253 | +// Load additional pages without refetching existing data |
| 254 | +const loadMoreTodos = async (page) => { |
| 255 | + const newTodos = await api.getTodos({ page, limit: 50 }) |
| 256 | + |
| 257 | + // Add new items without affecting existing ones |
| 258 | + todosCollection.utils.writeBatch(() => { |
| 259 | + newTodos.forEach(todo => { |
| 260 | + todosCollection.utils.writeInsert(todo) |
| 261 | + }) |
| 262 | + }) |
| 263 | +} |
| 264 | +``` |
| 265 | + |
| 266 | +## Important Behaviors |
| 267 | + |
| 268 | +### Full State Sync |
| 269 | + |
| 270 | +The query collection treats the `queryFn` result as the **complete state** of the collection. This means: |
| 271 | + |
| 272 | +- Items present in the collection but not in the query result will be deleted |
| 273 | +- Items in the query result but not in the collection will be inserted |
| 274 | +- Items present in both will be updated if they differ |
| 275 | + |
| 276 | +### Empty Array Behavior |
| 277 | + |
| 278 | +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". |
| 279 | + |
| 280 | +```typescript |
| 281 | +// This will delete all items in the collection |
| 282 | +queryFn: async () => [] |
| 283 | +``` |
| 284 | + |
| 285 | +### Handling Partial/Incremental Fetches |
| 286 | + |
| 287 | +Since the query collection expects `queryFn` to return the complete state, you can handle partial fetches by merging new data with existing data: |
| 288 | + |
| 289 | +```typescript |
| 290 | +const todosCollection = createCollection( |
| 291 | + queryCollectionOptions({ |
| 292 | + queryKey: ['todos'], |
| 293 | + queryFn: async ({ queryKey }) => { |
| 294 | + // Get existing data from cache |
| 295 | + const existingData = queryClient.getQueryData(queryKey) || [] |
| 296 | + |
| 297 | + // Fetch only new/updated items (e.g., changes since last sync) |
| 298 | + const lastSyncTime = localStorage.getItem('todos-last-sync') |
| 299 | + const newData = await fetch(`/api/todos?since=${lastSyncTime}`).then(r => r.json()) |
| 300 | + |
| 301 | + // Merge new data with existing data |
| 302 | + const existingMap = new Map(existingData.map(item => [item.id, item])) |
| 303 | + |
| 304 | + // Apply updates and additions |
| 305 | + newData.forEach(item => { |
| 306 | + existingMap.set(item.id, item) |
| 307 | + }) |
| 308 | + |
| 309 | + // Handle deletions if your API provides them |
| 310 | + if (newData.deletions) { |
| 311 | + newData.deletions.forEach(id => existingMap.delete(id)) |
| 312 | + } |
| 313 | + |
| 314 | + // Update sync time |
| 315 | + localStorage.setItem('todos-last-sync', new Date().toISOString()) |
| 316 | + |
| 317 | + // Return the complete merged state |
| 318 | + return Array.from(existingMap.values()) |
| 319 | + }, |
| 320 | + queryClient, |
| 321 | + getKey: (item) => item.id, |
| 322 | + }) |
| 323 | +) |
| 324 | +``` |
| 325 | + |
| 326 | +This pattern allows you to: |
| 327 | +- Fetch only incremental changes from your API |
| 328 | +- Merge those changes with existing data |
| 329 | +- Return the complete state that the collection expects |
| 330 | +- Avoid the performance overhead of fetching all data every time |
| 331 | + |
| 332 | +### Direct Writes and Query Sync |
| 333 | + |
| 334 | +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. |
| 335 | + |
| 336 | +To handle this properly: |
| 337 | +1. Use `{ refetch: false }` in your persistence handlers when using direct writes |
| 338 | +2. Set appropriate `staleTime` to prevent unnecessary refetches |
| 339 | +3. Design your `queryFn` to be aware of incremental updates (e.g., only fetch new data) |
| 340 | + |
| 341 | +## Complete Direct Write API Reference |
| 342 | + |
| 343 | +All direct write methods are available on `collection.utils`: |
| 344 | + |
| 345 | +- `writeInsert(data)`: Insert one or more items directly |
| 346 | +- `writeUpdate(data)`: Update one or more items directly |
| 347 | +- `writeDelete(keys)`: Delete one or more items directly |
| 348 | +- `writeUpsert(data)`: Insert or update one or more items directly |
| 349 | +- `writeBatch(callback)`: Perform multiple operations atomically |
| 350 | +- `refetch()`: Manually trigger a refetch of the query |
0 commit comments