Skip to content

Commit c2a5c28

Browse files
lucaswengKyleAMathewsclaude
authored
feat: add exact refetch targeting and improve utils.refetch() behavior (#552)
* feat: implement exact targeting for refetching queries to prevent unintended cascading effects * feat: add refetchType option for more granular refetching control * chore: add changeset * refactor: make utils.refetch() bypass enabled: false and remove refetchType Changes: - Use queryObserver.refetch() for all refetch calls (both utils and internal handlers) - Bypasses enabled: false to support manual fetch patterns (matches TanStack Query hook behavior) - Fixes clearError() to work even when enabled: false - Return QueryObserverResult instead of void for better DX - Remove refetchType option - not needed with exact targeting via observer - Add tests for clearError() exact targeting and throwOnError behavior - Update docs to clarify refetch semantics With exact targeting via queryObserver, refetchType filtering doesn't add value. Users always want their collection data refetched, whether from utils.refetch() or internal mutation handlers. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: clearError should return Promise<void> not QueryObserverResult * fix: type error in query.test --------- Co-authored-by: Kyle Mathews <mathews.kyle@gmail.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 518ecda commit c2a5c28

File tree

4 files changed

+404
-85
lines changed

4 files changed

+404
-85
lines changed

.changeset/soft-doodles-cover.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@tanstack/query-db-collection": patch
3+
---
4+
5+
**Behavior change**: `utils.refetch()` now uses exact query key targeting (previously used prefix matching). This prevents unintended cascading refetches of related queries. For example, refetching `['todos', 'project-1']` will no longer trigger refetches of `['todos']` or `['todos', 'project-2']`.
6+
7+
Additionally, `utils.refetch()` now bypasses `enabled: false` to support manual/imperative refetch patterns (matching TanStack Query hook behavior) and returns `QueryObserverResult` instead of `void` for better DX.

docs/collections/query-collection.md

Lines changed: 76 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Query collections provide seamless integration between TanStack DB and TanStack
99
## Overview
1010

1111
The `@tanstack/query-db-collection` package allows you to create collections that:
12+
1213
- Automatically sync with remote data via TanStack Query
1314
- Support optimistic updates with automatic rollback on errors
1415
- Handle persistence through customizable mutation handlers
@@ -23,17 +24,17 @@ npm install @tanstack/query-db-collection @tanstack/query-core @tanstack/db
2324
## Basic Usage
2425

2526
```typescript
26-
import { QueryClient } from '@tanstack/query-core'
27-
import { createCollection } from '@tanstack/db'
28-
import { queryCollectionOptions } from '@tanstack/query-db-collection'
27+
import { QueryClient } from "@tanstack/query-core"
28+
import { createCollection } from "@tanstack/db"
29+
import { queryCollectionOptions } from "@tanstack/query-db-collection"
2930

3031
const queryClient = new QueryClient()
3132

3233
const todosCollection = createCollection(
3334
queryCollectionOptions({
34-
queryKey: ['todos'],
35+
queryKey: ["todos"],
3536
queryFn: async () => {
36-
const response = await fetch('/api/todos')
37+
const response = await fetch("/api/todos")
3738
return response.json()
3839
},
3940
queryClient,
@@ -55,7 +56,7 @@ The `queryCollectionOptions` function accepts the following options:
5556

5657
### Query Options
5758

58-
- `select`: Function that lets extract array items when theyre wrapped with metadata
59+
- `select`: Function that lets extract array items when they're wrapped with metadata
5960
- `enabled`: Whether the query should automatically run (default: `true`)
6061
- `refetchInterval`: Refetch interval in milliseconds
6162
- `retry`: Retry configuration for failed queries
@@ -83,30 +84,30 @@ You can define handlers that are called when mutations occur. These handlers can
8384
```typescript
8485
const todosCollection = createCollection(
8586
queryCollectionOptions({
86-
queryKey: ['todos'],
87+
queryKey: ["todos"],
8788
queryFn: fetchTodos,
8889
queryClient,
8990
getKey: (item) => item.id,
90-
91+
9192
onInsert: async ({ transaction }) => {
92-
const newItems = transaction.mutations.map(m => m.modified)
93+
const newItems = transaction.mutations.map((m) => m.modified)
9394
await api.createTodos(newItems)
9495
// Returning nothing or { refetch: true } will trigger a refetch
9596
// Return { refetch: false } to skip automatic refetch
9697
},
97-
98+
9899
onUpdate: async ({ transaction }) => {
99-
const updates = transaction.mutations.map(m => ({
100+
const updates = transaction.mutations.map((m) => ({
100101
id: m.key,
101-
changes: m.changes
102+
changes: m.changes,
102103
}))
103104
await api.updateTodos(updates)
104105
},
105-
106+
106107
onDelete: async ({ transaction }) => {
107-
const ids = transaction.mutations.map(m => m.key)
108+
const ids = transaction.mutations.map((m) => m.key)
108109
await api.deleteTodos(ids)
109-
}
110+
},
110111
})
111112
)
112113
```
@@ -119,14 +120,15 @@ You can control this behavior by returning an object with a `refetch` property:
119120

120121
```typescript
121122
onInsert: async ({ transaction }) => {
122-
await api.createTodos(transaction.mutations.map(m => m.modified))
123-
123+
await api.createTodos(transaction.mutations.map((m) => m.modified))
124+
124125
// Skip the automatic refetch
125126
return { refetch: false }
126127
}
127128
```
128129

129130
This is useful when:
131+
130132
- You're confident the server state matches what you sent
131133
- You want to avoid unnecessary network requests
132134
- You're handling state updates through other mechanisms (like WebSockets)
@@ -135,7 +137,10 @@ This is useful when:
135137

136138
The collection provides these utility methods via `collection.utils`:
137139

138-
- `refetch()`: Manually trigger a refetch of the query
140+
- `refetch(opts?)`: Manually trigger a refetch of the query
141+
- `opts.throwOnError`: Whether to throw an error if the refetch fails (default: `false`)
142+
- Bypasses `enabled: false` to support imperative/manual refetching patterns (similar to hook `refetch()` behavior)
143+
- Returns `QueryObserverResult` for inspecting the result
139144

140145
## Direct Writes
141146

@@ -144,10 +149,12 @@ Direct writes are intended for scenarios where the normal query/mutation flow do
144149
### Understanding the Data Stores
145150

146151
Query Collections maintain two data stores:
152+
147153
1. **Synced Data Store** - The authoritative state synchronized with the server via `queryFn`
148154
2. **Optimistic Mutations Store** - Temporary changes that are applied optimistically before server confirmation
149155

150156
Normal collection operations (insert, update, delete) create optimistic mutations that are:
157+
151158
- Applied immediately to the UI
152159
- Sent to the server via persistence handlers
153160
- Rolled back automatically if the server request fails
@@ -158,6 +165,7 @@ Direct writes bypass this system entirely and write directly to the synced data
158165
### When to Use Direct Writes
159166

160167
Direct writes should be used when:
168+
161169
- You need to sync real-time updates from WebSockets or server-sent events
162170
- You're dealing with large datasets where refetching everything is too expensive
163171
- You receive incremental updates or server-computed field updates
@@ -167,19 +175,28 @@ Direct writes should be used when:
167175

168176
```typescript
169177
// Insert a new item directly to the synced data store
170-
todosCollection.utils.writeInsert({ id: '1', text: 'Buy milk', completed: false })
178+
todosCollection.utils.writeInsert({
179+
id: "1",
180+
text: "Buy milk",
181+
completed: false,
182+
})
171183

172184
// Update an existing item in the synced data store
173-
todosCollection.utils.writeUpdate({ id: '1', completed: true })
185+
todosCollection.utils.writeUpdate({ id: "1", completed: true })
174186

175187
// Delete an item from the synced data store
176-
todosCollection.utils.writeDelete('1')
188+
todosCollection.utils.writeDelete("1")
177189

178190
// Upsert (insert or update) in the synced data store
179-
todosCollection.utils.writeUpsert({ id: '1', text: 'Buy milk', completed: false })
191+
todosCollection.utils.writeUpsert({
192+
id: "1",
193+
text: "Buy milk",
194+
completed: false,
195+
})
180196
```
181197

182198
These operations:
199+
183200
- Write directly to the synced data store
184201
- Do NOT create optimistic mutations
185202
- Do NOT trigger automatic query refetches
@@ -192,28 +209,28 @@ The `writeBatch` method allows you to perform multiple operations atomically. An
192209

193210
```typescript
194211
todosCollection.utils.writeBatch(() => {
195-
todosCollection.utils.writeInsert({ id: '1', text: 'Buy milk' })
196-
todosCollection.utils.writeInsert({ id: '2', text: 'Walk dog' })
197-
todosCollection.utils.writeUpdate({ id: '3', completed: true })
198-
todosCollection.utils.writeDelete('4')
212+
todosCollection.utils.writeInsert({ id: "1", text: "Buy milk" })
213+
todosCollection.utils.writeInsert({ id: "2", text: "Walk dog" })
214+
todosCollection.utils.writeUpdate({ id: "3", completed: true })
215+
todosCollection.utils.writeDelete("4")
199216
})
200217
```
201218

202219
### Real-World Example: WebSocket Integration
203220

204221
```typescript
205222
// Handle real-time updates from WebSocket without triggering full refetches
206-
ws.on('todos:update', (changes) => {
223+
ws.on("todos:update", (changes) => {
207224
todosCollection.utils.writeBatch(() => {
208-
changes.forEach(change => {
225+
changes.forEach((change) => {
209226
switch (change.type) {
210-
case 'insert':
227+
case "insert":
211228
todosCollection.utils.writeInsert(change.data)
212229
break
213-
case 'update':
230+
case "update":
214231
todosCollection.utils.writeUpdate(change.data)
215232
break
216-
case 'delete':
233+
case "delete":
217234
todosCollection.utils.writeDelete(change.id)
218235
break
219236
}
@@ -229,21 +246,21 @@ When the server returns computed fields (like server-generated IDs or timestamps
229246
```typescript
230247
const todosCollection = createCollection(
231248
queryCollectionOptions({
232-
queryKey: ['todos'],
249+
queryKey: ["todos"],
233250
queryFn: fetchTodos,
234251
queryClient,
235252
getKey: (item) => item.id,
236253

237254
onInsert: async ({ transaction }) => {
238-
const newItems = transaction.mutations.map(m => m.modified)
255+
const newItems = transaction.mutations.map((m) => m.modified)
239256

240257
// Send to server and get back items with server-computed fields
241258
const serverItems = await api.createTodos(newItems)
242259

243260
// Sync server-computed fields (like server-generated IDs, timestamps, etc.)
244261
// to the collection's synced data store
245262
todosCollection.utils.writeBatch(() => {
246-
serverItems.forEach(serverItem => {
263+
serverItems.forEach((serverItem) => {
247264
todosCollection.utils.writeInsert(serverItem)
248265
})
249266
})
@@ -254,26 +271,26 @@ const todosCollection = createCollection(
254271
},
255272

256273
onUpdate: async ({ transaction }) => {
257-
const updates = transaction.mutations.map(m => ({
274+
const updates = transaction.mutations.map((m) => ({
258275
id: m.key,
259-
changes: m.changes
276+
changes: m.changes,
260277
}))
261278
const serverItems = await api.updateTodos(updates)
262279

263280
// Sync server-computed fields from the update response
264281
todosCollection.utils.writeBatch(() => {
265-
serverItems.forEach(serverItem => {
282+
serverItems.forEach((serverItem) => {
266283
todosCollection.utils.writeUpdate(serverItem)
267284
})
268285
})
269286

270287
return { refetch: false }
271-
}
288+
},
272289
})
273290
)
274291

275292
// Usage is just like a regular collection
276-
todosCollection.insert({ text: 'Buy milk', completed: false })
293+
todosCollection.insert({ text: "Buy milk", completed: false })
277294
```
278295

279296
### Example: Large Dataset Pagination
@@ -282,10 +299,10 @@ todosCollection.insert({ text: 'Buy milk', completed: false })
282299
// Load additional pages without refetching existing data
283300
const loadMoreTodos = async (page) => {
284301
const newTodos = await api.getTodos({ page, limit: 50 })
285-
302+
286303
// Add new items without affecting existing ones
287304
todosCollection.utils.writeBatch(() => {
288-
newTodos.forEach(todo => {
305+
newTodos.forEach((todo) => {
289306
todosCollection.utils.writeInsert(todo)
290307
})
291308
})
@@ -318,31 +335,33 @@ Since the query collection expects `queryFn` to return the complete state, you c
318335
```typescript
319336
const todosCollection = createCollection(
320337
queryCollectionOptions({
321-
queryKey: ['todos'],
338+
queryKey: ["todos"],
322339
queryFn: async ({ queryKey }) => {
323340
// Get existing data from cache
324341
const existingData = queryClient.getQueryData(queryKey) || []
325-
342+
326343
// Fetch only new/updated items (e.g., changes since last sync)
327-
const lastSyncTime = localStorage.getItem('todos-last-sync')
328-
const newData = await fetch(`/api/todos?since=${lastSyncTime}`).then(r => r.json())
329-
344+
const lastSyncTime = localStorage.getItem("todos-last-sync")
345+
const newData = await fetch(`/api/todos?since=${lastSyncTime}`).then(
346+
(r) => r.json()
347+
)
348+
330349
// Merge new data with existing data
331-
const existingMap = new Map(existingData.map(item => [item.id, item]))
332-
350+
const existingMap = new Map(existingData.map((item) => [item.id, item]))
351+
333352
// Apply updates and additions
334-
newData.forEach(item => {
353+
newData.forEach((item) => {
335354
existingMap.set(item.id, item)
336355
})
337-
356+
338357
// Handle deletions if your API provides them
339358
if (newData.deletions) {
340-
newData.deletions.forEach(id => existingMap.delete(id))
359+
newData.deletions.forEach((id) => existingMap.delete(id))
341360
}
342-
361+
343362
// Update sync time
344-
localStorage.setItem('todos-last-sync', new Date().toISOString())
345-
363+
localStorage.setItem("todos-last-sync", new Date().toISOString())
364+
346365
// Return the complete merged state
347366
return Array.from(existingMap.values())
348367
},
@@ -353,6 +372,7 @@ const todosCollection = createCollection(
353372
```
354373

355374
This pattern allows you to:
375+
356376
- Fetch only incremental changes from your API
357377
- Merge those changes with existing data
358378
- Return the complete state that the collection expects
@@ -363,6 +383,7 @@ This pattern allows you to:
363383
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.
364384

365385
To handle this properly:
386+
366387
1. Use `{ refetch: false }` in your persistence handlers when using direct writes
367388
2. Set appropriate `staleTime` to prevent unnecessary refetches
368389
3. Design your `queryFn` to be aware of incremental updates (e.g., only fetch new data)
@@ -376,4 +397,4 @@ All direct write methods are available on `collection.utils`:
376397
- `writeDelete(keys)`: Delete one or more items directly
377398
- `writeUpsert(data)`: Insert or update one or more items directly
378399
- `writeBatch(callback)`: Perform multiple operations atomically
379-
- `refetch()`: Manually trigger a refetch of the query
400+
- `refetch(opts?)`: Manually trigger a refetch of the query

0 commit comments

Comments
 (0)