Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/soft-doodles-cover.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@tanstack/query-db-collection": patch
---

**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']`.

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.
131 changes: 76 additions & 55 deletions docs/collections/query-collection.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Query collections provide seamless integration between TanStack DB and TanStack
## Overview

The `@tanstack/query-db-collection` package allows you to create collections that:

- Automatically sync with remote data via TanStack Query
- Support optimistic updates with automatic rollback on errors
- Handle persistence through customizable mutation handlers
Expand All @@ -23,17 +24,17 @@ npm install @tanstack/query-db-collection @tanstack/query-core @tanstack/db
## Basic Usage

```typescript
import { QueryClient } from '@tanstack/query-core'
import { createCollection } from '@tanstack/db'
import { queryCollectionOptions } from '@tanstack/query-db-collection'
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'],
queryKey: ["todos"],
queryFn: async () => {
const response = await fetch('/api/todos')
const response = await fetch("/api/todos")
return response.json()
},
queryClient,
Expand All @@ -55,7 +56,7 @@ The `queryCollectionOptions` function accepts the following options:

### Query Options

- `select`: Function that lets extract array items when theyre wrapped with metadata
- `select`: Function that lets extract array items when they're wrapped with metadata
- `enabled`: Whether the query should automatically run (default: `true`)
- `refetchInterval`: Refetch interval in milliseconds
- `retry`: Retry configuration for failed queries
Expand Down Expand Up @@ -83,30 +84,30 @@ You can define handlers that are called when mutations occur. These handlers can
```typescript
const todosCollection = createCollection(
queryCollectionOptions({
queryKey: ['todos'],
queryKey: ["todos"],
queryFn: fetchTodos,
queryClient,
getKey: (item) => item.id,

onInsert: async ({ transaction }) => {
const newItems = transaction.mutations.map(m => m.modified)
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 => ({
const updates = transaction.mutations.map((m) => ({
id: m.key,
changes: m.changes
changes: m.changes,
}))
await api.updateTodos(updates)
},

onDelete: async ({ transaction }) => {
const ids = transaction.mutations.map(m => m.key)
const ids = transaction.mutations.map((m) => m.key)
await api.deleteTodos(ids)
}
},
})
)
```
Expand All @@ -119,14 +120,15 @@ You can control this behavior by returning an object with a `refetch` property:

```typescript
onInsert: async ({ transaction }) => {
await api.createTodos(transaction.mutations.map(m => m.modified))
await api.createTodos(transaction.mutations.map((m) => m.modified))

// Skip the automatic refetch
return { refetch: false }
}
```

This is useful when:

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

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

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

## Direct Writes

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

Query Collections maintain two data stores:

1. **Synced Data Store** - The authoritative state synchronized with the server via `queryFn`
2. **Optimistic Mutations Store** - Temporary changes that are applied optimistically before server confirmation

Normal collection operations (insert, update, delete) create optimistic mutations that are:

- Applied immediately to the UI
- Sent to the server via persistence handlers
- Rolled back automatically if the server request fails
Expand All @@ -158,6 +165,7 @@ Direct writes bypass this system entirely and write directly to the synced data
### When to Use Direct Writes

Direct writes should be used when:

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

```typescript
// Insert a new item directly to the synced data store
todosCollection.utils.writeInsert({ id: '1', text: 'Buy milk', completed: false })
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 })
todosCollection.utils.writeUpdate({ id: "1", completed: true })

// Delete an item from the synced data store
todosCollection.utils.writeDelete('1')
todosCollection.utils.writeDelete("1")

// Upsert (insert or update) in the synced data store
todosCollection.utils.writeUpsert({ id: '1', text: 'Buy milk', completed: false })
todosCollection.utils.writeUpsert({
id: "1",
text: "Buy milk",
completed: false,
})
```

These operations:

- Write directly to the synced data store
- Do NOT create optimistic mutations
- Do NOT trigger automatic query refetches
Expand All @@ -192,28 +209,28 @@ The `writeBatch` method allows you to perform multiple operations atomically. An

```typescript
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.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")
})
```

### Real-World Example: WebSocket Integration

```typescript
// Handle real-time updates from WebSocket without triggering full refetches
ws.on('todos:update', (changes) => {
ws.on("todos:update", (changes) => {
todosCollection.utils.writeBatch(() => {
changes.forEach(change => {
changes.forEach((change) => {
switch (change.type) {
case 'insert':
case "insert":
todosCollection.utils.writeInsert(change.data)
break
case 'update':
case "update":
todosCollection.utils.writeUpdate(change.data)
break
case 'delete':
case "delete":
todosCollection.utils.writeDelete(change.id)
break
}
Expand All @@ -229,21 +246,21 @@ When the server returns computed fields (like server-generated IDs or timestamps
```typescript
const todosCollection = createCollection(
queryCollectionOptions({
queryKey: ['todos'],
queryKey: ["todos"],
queryFn: fetchTodos,
queryClient,
getKey: (item) => item.id,

onInsert: async ({ transaction }) => {
const newItems = transaction.mutations.map(m => m.modified)
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 => {
serverItems.forEach((serverItem) => {
todosCollection.utils.writeInsert(serverItem)
})
})
Expand All @@ -254,26 +271,26 @@ const todosCollection = createCollection(
},

onUpdate: async ({ transaction }) => {
const updates = transaction.mutations.map(m => ({
const updates = transaction.mutations.map((m) => ({
id: m.key,
changes: m.changes
changes: m.changes,
}))
const serverItems = await api.updateTodos(updates)

// Sync server-computed fields from the update response
todosCollection.utils.writeBatch(() => {
serverItems.forEach(serverItem => {
serverItems.forEach((serverItem) => {
todosCollection.utils.writeUpdate(serverItem)
})
})

return { refetch: false }
}
},
})
)

// Usage is just like a regular collection
todosCollection.insert({ text: 'Buy milk', completed: false })
todosCollection.insert({ text: "Buy milk", completed: false })
```

### Example: Large Dataset Pagination
Expand All @@ -282,10 +299,10 @@ 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 => {
newTodos.forEach((todo) => {
todosCollection.utils.writeInsert(todo)
})
})
Expand Down Expand Up @@ -318,31 +335,33 @@ Since the query collection expects `queryFn` to return the complete state, you c
```typescript
const todosCollection = createCollection(
queryCollectionOptions({
queryKey: ['todos'],
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())

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]))
const existingMap = new Map(existingData.map((item) => [item.id, item]))

// Apply updates and additions
newData.forEach(item => {
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))
newData.deletions.forEach((id) => existingMap.delete(id))
}

// Update sync time
localStorage.setItem('todos-last-sync', new Date().toISOString())
localStorage.setItem("todos-last-sync", new Date().toISOString())

// Return the complete merged state
return Array.from(existingMap.values())
},
Expand All @@ -353,6 +372,7 @@ const todosCollection = createCollection(
```

This pattern allows you to:

- Fetch only incremental changes from your API
- Merge those changes with existing data
- Return the complete state that the collection expects
Expand All @@ -363,6 +383,7 @@ 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:

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