Skip to content
Draft
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
49 changes: 49 additions & 0 deletions .changeset/deprecate-handler-return-values.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
"@tanstack/db": major
"@tanstack/electric-db-collection": major
"@tanstack/query-db-collection": major
---

**BREAKING (TypeScript only)**: Deprecate returning values from mutation handlers (`onInsert`, `onUpdate`, `onDelete`).

**What's changed:**
- Handler types now default to `Promise<void>` instead of `Promise<any>`
- TypeScript will error on `return { refetch: false }` or `return { txid }`
- Runtime still supports old return patterns for backward compatibility
- **Deprecation warnings** are now logged when handlers return values
- Old patterns will be fully removed in v1.0 RC

**New pattern (explicit sync coordination):**
- **Query Collections**: Call `await collection.utils.refetch()` to sync server state
- **Electric Collections**: Call `await collection.utils.awaitTxId(txid)` or `await collection.utils.awaitMatch(fn)` to wait for synchronization
- **Other Collections**: Use appropriate sync utilities for your collection type

This change makes the API more explicit and consistent across all collection types. All handlers should coordinate sync explicitly within the handler function using `await`, rather than relying on magic return values.

Migration guide:

```typescript
// Before (Query Collection)
onInsert: async ({ transaction }) => {
await api.create(transaction.mutations[0].modified)
// Implicitly refetches
}

// After (Query Collection)
onInsert: async ({ transaction, collection }) => {
await api.create(transaction.mutations[0].modified)
await collection.utils.refetch()
}

// Before (Electric Collection)
onInsert: async ({ transaction }) => {
const result = await api.create(transaction.mutations[0].modified)
return { txid: result.txid }
}

// After (Electric Collection)
onInsert: async ({ transaction, collection }) => {
const result = await api.create(transaction.mutations[0].modified)
await collection.utils.awaitTxId(result.txid)
}
```
17 changes: 9 additions & 8 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,7 +306,7 @@ 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
Expand All @@ -319,7 +320,7 @@ This is useful when you need to ensure a mutation has been synchronized before p

### `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
71 changes: 41 additions & 30 deletions docs/collections/query-collection.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ The `queryCollectionOptions` function accepts the following options:

## 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 and trigger refetches when needed:

```typescript
const todosCollection = createCollection(
Expand All @@ -89,55 +89,67 @@ const todosCollection = createCollection(
queryClient,
getKey: (item) => item.id,

onInsert: async ({ transaction }) => {
onInsert: async ({ transaction, collection }) => {
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
// Trigger refetch to sync server state
await collection.utils.refetch()
},

onUpdate: async ({ transaction }) => {
onUpdate: async ({ transaction, collection }) => {
const updates = transaction.mutations.map((m) => ({
id: m.key,
changes: m.changes,
}))
await api.updateTodos(updates)
// Refetch after persisting changes
await collection.utils.refetch()
},

onDelete: async ({ transaction }) => {
onDelete: async ({ transaction, collection }) => {
const ids = transaction.mutations.map((m) => m.key)
await api.deleteTodos(ids)
await collection.utils.refetch()
},
})
)
```

### 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.

You can control this behavior by returning an object with a `refetch` property:
After persisting mutations to your backend, call `collection.utils.refetch()` to sync the server state back to your collection. This ensures the local state matches the server state after server-side processing.

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

// Skip the automatic refetch
return { refetch: false }
// Trigger refetch to sync server state
await collection.utils.refetch()
}
```

This is useful when:
You can skip the refetch 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

**When to skip refetch:**

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

- 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)
// Skip refetch - only do this if server doesn't modify the data
// The optimistic state will remain as-is
}
```

## 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 @@ -241,7 +253,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 @@ -251,40 +263,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 @@ -384,7 +395,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 @@ -397,7 +408,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