Skip to content

Commit 1220d7e

Browse files
KyleAMathewsclaude
andauthored
feat: improve writeBatch API to use callback pattern (#378)
Co-authored-by: Claude <[email protected]>
1 parent 20a6693 commit 1220d7e

File tree

6 files changed

+720
-40
lines changed

6 files changed

+720
-40
lines changed

.changeset/dirty-birds-accept.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@tanstack/query-db-collection": minor
3+
---
4+
5+
Improve writeBatch API to use callback pattern
6+
7+
- Changed `writeBatch` from accepting an array of operations to accepting a callback function
8+
- Write operations called within the callback are automatically batched together
9+
- This provides a more intuitive API similar to database transactions
10+
- Added comprehensive documentation for Query Collections including direct writes feature

docs/collections/query-collection.md

Lines changed: 350 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,350 @@
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

docs/config.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,15 @@
7878
}
7979
]
8080
},
81+
{
82+
"label": "Collections",
83+
"children": [
84+
{
85+
"label": "Query Collection",
86+
"to": "collections/query-collection"
87+
}
88+
]
89+
},
8190
{
8291
"label": "API Reference",
8392
"children": [

0 commit comments

Comments
 (0)