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
50 changes: 50 additions & 0 deletions .changeset/deprecate-handler-return-values.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
'@tanstack/db': minor
'@tanstack/electric-db-collection': minor
'@tanstack/query-db-collection': minor
---

**Deprecation**: Mutation handler return values and QueryCollection auto-refetch behavior.

**What's changed:**

- Handler types now default to `Promise<void>` instead of `Promise<any>`, indicating the new expected pattern
- **Deprecation warnings** are logged when deprecated patterns are used

**QueryCollection changes:**

- Auto-refetch after handlers is **deprecated** and will be removed in v1.0
- To skip auto-refetch now, return `{ refetch: false }` from your handler
- In v1.0: call `await collection.utils.refetch()` explicitly when needed, or omit to skip

**ElectricCollection changes:**

- Returning `{ txid }` is deprecated - use `await collection.utils.awaitTxId(txid)` instead

**Migration guide:**

```typescript
// QueryCollection - skip refetch (current)
onInsert: async ({ transaction }) => {
await api.create(transaction.mutations[0].modified)
return { refetch: false } // Opt out of auto-refetch
}

// QueryCollection - with refetch (v1.0 pattern)
onInsert: async ({ transaction, collection }) => {
await api.create(transaction.mutations[0].modified)
await collection.utils.refetch() // Explicit refetch
}

// ElectricCollection - before
onInsert: async ({ transaction }) => {
const result = await api.create(transaction.mutations[0].modified)
return { txid: result.txid } // Deprecated
}

// ElectricCollection - after
onInsert: async ({ transaction, collection }) => {
const result = await api.create(transaction.mutations[0].modified)
await collection.utils.awaitTxId(result.txid) // Explicit
}
```
19 changes: 10 additions & 9 deletions docs/collections/electric-collection.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ Handlers are called before mutations to persist changes to your backend:
- `onUpdate`: Handler called before update operations
- `onDelete`: Handler called before delete operations

Each handler should return `{ txid }` to wait for synchronization. For cases where your API can not return txids, use the `awaitMatch` utility function.
Each handler should call `await collection.utils.awaitTxId(txid)` to wait for synchronization. For cases where your API cannot return txids, use the `awaitMatch` utility function.

## Persistence Handlers & Synchronization

Expand All @@ -81,22 +81,23 @@ const todosCollection = createCollection(
params: { table: 'todos' },
},

onInsert: async ({ transaction }) => {
onInsert: async ({ transaction, collection }) => {
const newItem = transaction.mutations[0].modified
const response = await api.todos.create(newItem)

// Return txid to wait for sync
return { txid: response.txid }
// Wait for txid to sync
await collection.utils.awaitTxId(response.txid)
},

onUpdate: async ({ transaction }) => {
onUpdate: async ({ transaction, collection }) => {
const { original, changes } = transaction.mutations[0]
const response = await api.todos.update({
where: { id: original.id },
data: changes
})

return { txid: response.txid }
// Wait for txid to sync
await collection.utils.awaitTxId(response.txid)
}
})
)
Expand Down Expand Up @@ -305,21 +306,21 @@ The collection provides these utility methods via `collection.utils`:

### `awaitTxId(txid, timeout?)`

Manually wait for a specific transaction ID to be synchronized:
Wait for a specific transaction ID to be synchronized:

```typescript
// Wait for specific txid
await todosCollection.utils.awaitTxId(12345)

// With custom timeout (default is 30 seconds)
// With custom timeout (default is 15 seconds)
await todosCollection.utils.awaitTxId(12345, 10000)
```

This is useful when you need to ensure a mutation has been synchronized before proceeding with other operations.

### `awaitMatch(matchFn, timeout?)`

Manually wait for a custom match function to find a matching message:
Wait for a custom match function to find a matching message:

```typescript
import { isChangeMessage } from '@tanstack/electric-db-collection'
Expand Down
77 changes: 53 additions & 24 deletions docs/collections/query-collection.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ const productsCollection = createCollection(

## Persistence Handlers

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:
You can define handlers that are called when mutations occur. These handlers persist changes to your backend:

```typescript
const todosCollection = createCollection(
Expand All @@ -212,8 +212,7 @@ const todosCollection = createCollection(
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
// Auto-refetch happens after handler completes (pre-1.0 behavior)
},

onUpdate: async ({ transaction }) => {
Expand All @@ -232,32 +231,63 @@ const todosCollection = createCollection(
)
```

> **Note**: QueryCollection currently auto-refetches after handlers complete. See [Controlling Refetch Behavior](#controlling-refetch-behavior) for details on this transitional behavior.

### Controlling Refetch Behavior

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.
> **⚠️ Transitional API**: QueryCollection currently auto-refetches after handlers complete. This behavior is deprecated and will be removed in v1.0. See the migration notes below.

#### Current Behavior (Pre-1.0)

You can control this behavior by returning an object with a `refetch` property:
By default, QueryCollection automatically refetches after each handler completes. To **skip** auto-refetch, return `{ refetch: false }`:

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

// Skip the automatic refetch
// Skip auto-refetch - use this when server doesn't modify the data
return { refetch: false }
}
```

This is useful when:
If you don't return `{ refetch: false }`, auto-refetch happens automatically.

#### v1.0 Behavior (Future)

In v1.0, auto-refetch will be **removed**. Handlers will need to explicitly call `collection.utils.refetch()` when refetching is needed:

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

// Explicitly trigger refetch when you need server state
await collection.utils.refetch()
}
```

To skip refetch in v1.0, simply don't call `refetch()`:

- 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)
```typescript
onInsert: async ({ transaction }) => {
await api.createTodos(transaction.mutations.map((m) => m.modified))

// No refetch call = no refetch (v1.0 behavior)
}
```

#### When to Skip Refetch

Skip refetching when:

- You're confident the server state exactly matches what you sent (no server-side processing)
- You're handling state updates through other mechanisms (like WebSockets or direct writes)
- You want to optimize for fewer network requests

## Utility Methods

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

- `refetch(opts?)`: Manually trigger a refetch of the query
- `refetch(opts?)`: 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
Expand Down Expand Up @@ -361,7 +391,7 @@ ws.on("todos:update", (changes) => {

### Example: Incremental Updates

When the server returns computed fields (like server-generated IDs or timestamps), you can use the `onInsert` handler with `{ refetch: false }` to avoid unnecessary refetches while still syncing the server response:
When the server returns computed fields (like server-generated IDs or timestamps), you can use direct writes to sync the server response without triggering a full refetch:

```typescript
const todosCollection = createCollection(
Expand All @@ -371,40 +401,39 @@ const todosCollection = createCollection(
queryClient,
getKey: (item) => item.id,

onInsert: async ({ transaction }) => {
onInsert: async ({ transaction, collection }) => {
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(() => {
// to the collection's synced data store using direct writes
collection.utils.writeBatch(() => {
serverItems.forEach((serverItem) => {
todosCollection.utils.writeInsert(serverItem)
collection.utils.writeInsert(serverItem)
})
})

// Skip automatic refetch since we've already synced the server response
// No need to refetch - we've already synced the server response via direct writes
// (optimistic state is automatically replaced when handler completes)
return { refetch: false }
},

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

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

return { refetch: false }
// No refetch needed since we used direct writes
},
})
)
Expand Down Expand Up @@ -504,7 +533,7 @@ Direct writes update the collection immediately and also update the TanStack Que

To handle this properly:

1. Use `{ refetch: false }` in your persistence handlers when using direct writes
1. Skip calling `collection.utils.refetch()` 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 @@ -517,7 +546,7 @@ 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(opts?)`: Manually trigger a refetch of the query
- `refetch(opts?)`: Trigger a refetch of the query

## QueryFn and Predicate Push-Down

Expand Down
Loading
Loading