Skip to content

Commit 7dff639

Browse files
KyleAMathewsclaude
andcommitted
feat: add warnOnce utility and improve deprecation warnings
- Add warnOnce utility to prevent console spam (logs each warning once) - QueryCollection: warn about deprecated auto-refetch behavior - QueryCollection: clarify { refetch: false } is correct pattern for now - ElectricCollection: use warnOnce for { txid } return deprecation - Update docs to accurately describe current vs v1.0 behavior - Update changeset with clearer migration guidance Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent bbd00ac commit 7dff639

File tree

6 files changed

+161
-84
lines changed

6 files changed

+161
-84
lines changed

.changeset/deprecate-handler-return-values.md

Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,47 +4,48 @@
44
'@tanstack/query-db-collection': minor
55
---
66

7-
**BREAKING (TypeScript only)**: Deprecate returning values from mutation handlers (`onInsert`, `onUpdate`, `onDelete`).
7+
**Deprecation**: Mutation handler return values and QueryCollection auto-refetch behavior.
88

99
**What's changed:**
1010

1111
- Handler types now default to `Promise<void>` instead of `Promise<any>`, indicating the new expected pattern
12-
- Old return patterns (`return { refetch }`, `return { txid }`) still work at runtime with deprecation warnings
13-
- **Deprecation warnings** are now logged when handlers return values
14-
- Old patterns will be fully removed in v1.0
12+
- **Deprecation warnings** are now logged (once per session) when deprecated patterns are used
13+
- Warnings now use `warnOnce` to avoid console spam
1514

16-
**New pattern (explicit sync coordination):**
15+
**QueryCollection changes:**
1716

18-
- **Query Collections**: Call `await collection.utils.refetch()` to sync server state
19-
- **Electric Collections**: Call `await collection.utils.awaitTxId(txid)` or `await collection.utils.awaitMatch(fn)` to wait for synchronization
20-
- **Other Collections**: Use appropriate sync utilities for your collection type
17+
- Auto-refetch after handlers is **deprecated** and will be removed in v1.0
18+
- To skip auto-refetch now, return `{ refetch: false }` from your handler
19+
- In v1.0: call `await collection.utils.refetch()` explicitly when needed, or omit to skip
2120

22-
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.
21+
**ElectricCollection changes:**
2322

24-
Migration guide:
23+
- Returning `{ txid }` is deprecated - use `await collection.utils.awaitTxId(txid)` instead
24+
25+
**Migration guide:**
2526

2627
```typescript
27-
// Before (Query Collection)
28+
// QueryCollection - skip refetch (current)
2829
onInsert: async ({ transaction }) => {
2930
await api.create(transaction.mutations[0].modified)
30-
// Implicitly refetches
31+
return { refetch: false } // Opt out of auto-refetch
3132
}
3233

33-
// After (Query Collection)
34+
// QueryCollection - with refetch (v1.0 pattern)
3435
onInsert: async ({ transaction, collection }) => {
3536
await api.create(transaction.mutations[0].modified)
36-
await collection.utils.refetch()
37+
await collection.utils.refetch() // Explicit refetch
3738
}
3839

39-
// Before (Electric Collection)
40+
// ElectricCollection - before
4041
onInsert: async ({ transaction }) => {
4142
const result = await api.create(transaction.mutations[0].modified)
42-
return { txid: result.txid }
43+
return { txid: result.txid } // Deprecated
4344
}
4445

45-
// After (Electric Collection)
46+
// ElectricCollection - after
4647
onInsert: async ({ transaction, collection }) => {
4748
const result = await api.create(transaction.mutations[0].modified)
48-
await collection.utils.awaitTxId(result.txid)
49+
await collection.utils.awaitTxId(result.txid) // Explicit
4950
}
5051
```

docs/collections/query-collection.md

Lines changed: 38 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ const productsCollection = createCollection(
199199

200200
## Persistence Handlers
201201

202-
You can define handlers that are called when mutations occur. These handlers persist changes to your backend and trigger refetches when needed:
202+
You can define handlers that are called when mutations occur. These handlers persist changes to your backend:
203203

204204
```typescript
205205
const todosCollection = createCollection(
@@ -209,62 +209,80 @@ const todosCollection = createCollection(
209209
queryClient,
210210
getKey: (item) => item.id,
211211

212-
onInsert: async ({ transaction, collection }) => {
212+
onInsert: async ({ transaction }) => {
213213
const newItems = transaction.mutations.map((m) => m.modified)
214214
await api.createTodos(newItems)
215-
// Trigger refetch to sync server state
216-
await collection.utils.refetch()
215+
// Auto-refetch happens after handler completes (pre-1.0 behavior)
217216
},
218217

219-
onUpdate: async ({ transaction, collection }) => {
218+
onUpdate: async ({ transaction }) => {
220219
const updates = transaction.mutations.map((m) => ({
221220
id: m.key,
222221
changes: m.changes,
223222
}))
224223
await api.updateTodos(updates)
225-
// Refetch after persisting changes
226-
await collection.utils.refetch()
227224
},
228225

229-
onDelete: async ({ transaction, collection }) => {
226+
onDelete: async ({ transaction }) => {
230227
const ids = transaction.mutations.map((m) => m.key)
231228
await api.deleteTodos(ids)
232-
await collection.utils.refetch()
233229
},
234230
})
235231
)
236232
```
237233

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

240-
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.
238+
> **⚠️ 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.
239+
240+
#### Current Behavior (Pre-1.0)
241+
242+
By default, QueryCollection automatically refetches after each handler completes. To **skip** auto-refetch, return `{ refetch: false }`:
241243

242244
```typescript
243-
onInsert: async ({ transaction, collection }) => {
245+
onInsert: async ({ transaction }) => {
244246
await api.createTodos(transaction.mutations.map((m) => m.modified))
245247

246-
// Trigger refetch to sync server state
247-
await collection.utils.refetch()
248+
// Skip auto-refetch - use this when server doesn't modify the data
249+
return { refetch: false }
248250
}
249251
```
250252

251-
You can skip the refetch when:
253+
If you don't return `{ refetch: false }`, auto-refetch happens automatically.
252254

253-
- You're confident the server state exactly matches what you sent (no server-side processing)
254-
- You're handling state updates through other mechanisms (like WebSockets or direct writes)
255-
- You want to optimize for fewer network requests
255+
#### v1.0 Behavior (Future)
256+
257+
In v1.0, auto-refetch will be **removed**. Handlers will need to explicitly call `collection.utils.refetch()` when refetching is needed:
258+
259+
```typescript
260+
onInsert: async ({ transaction, collection }) => {
261+
await api.createTodos(transaction.mutations.map((m) => m.modified))
262+
263+
// Explicitly trigger refetch when you need server state
264+
await collection.utils.refetch()
265+
}
266+
```
256267

257-
**When to skip refetch:**
268+
To skip refetch in v1.0, simply don't call `refetch()`:
258269

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

263-
// Skip refetch - only do this if server doesn't modify the data
264-
// The optimistic state will remain as-is
274+
// No refetch call = no refetch (v1.0 behavior)
265275
}
266276
```
267277

278+
#### When to Skip Refetch
279+
280+
Skip refetching when:
281+
282+
- You're confident the server state exactly matches what you sent (no server-side processing)
283+
- You're handling state updates through other mechanisms (like WebSockets or direct writes)
284+
- You want to optimize for fewer network requests
285+
268286
## Utility Methods
269287

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

packages/db/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export * from './optimistic-action'
1313
export * from './local-only'
1414
export * from './local-storage'
1515
export * from './errors'
16-
export { deepEquals } from './utils'
16+
export { deepEquals, warnOnce, resetWarnings } from './utils'
1717
export * from './paced-mutations'
1818
export * from './strategies/index.js'
1919

packages/db/src/utils.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,3 +237,40 @@ export const DEFAULT_COMPARE_OPTIONS: CompareOptions = {
237237
nulls: `first`,
238238
stringSort: `locale`,
239239
}
240+
241+
/**
242+
* Set of warning keys that have already been shown.
243+
* Used to prevent duplicate warnings from spamming the console.
244+
*/
245+
const warnedKeys = new Set<string>()
246+
247+
/**
248+
* Log a warning message only once per unique key.
249+
* Subsequent calls with the same key will be silently ignored.
250+
*
251+
* @param key - Unique identifier for this warning
252+
* @param message - The warning message to display
253+
*
254+
* @example
255+
* ```typescript
256+
* // First call logs the warning
257+
* warnOnce('deprecated-api', 'This API is deprecated')
258+
*
259+
* // Subsequent calls with same key are ignored
260+
* warnOnce('deprecated-api', 'This API is deprecated') // silent
261+
* ```
262+
*/
263+
export function warnOnce(key: string, message: string): void {
264+
if (warnedKeys.has(key)) {
265+
return
266+
}
267+
warnedKeys.add(key)
268+
console.warn(message)
269+
}
270+
271+
/**
272+
* Reset all warning states. Primarily useful for testing.
273+
*/
274+
export function resetWarnings(): void {
275+
warnedKeys.clear()
276+
}

packages/electric-db-collection/src/electric.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
} from '@electric-sql/client'
77
import { Store } from '@tanstack/store'
88
import DebugModule from 'debug'
9-
import { DeduplicatedLoadSubset, and } from '@tanstack/db'
9+
import { DeduplicatedLoadSubset, and, warnOnce } from '@tanstack/db'
1010
import {
1111
ExpectedNumberInAwaitTxIdError,
1212
StreamAbortedError,
@@ -837,7 +837,8 @@ export function electricCollectionOptions<T extends Row<unknown>>(
837837
// Only wait if result contains txid
838838
if (result && `txid` in result) {
839839
// Warn about deprecated return value pattern
840-
console.warn(
840+
warnOnce(
841+
'electric-collection-txid-return',
841842
'[TanStack DB] DEPRECATED: Returning { txid } from mutation handlers is deprecated and will be removed in v1.0. ' +
842843
'Use `await collection.utils.awaitTxId(txid)` instead of returning { txid }. ' +
843844
'See migration guide: https://tanstack.com/db/latest/docs/collections/electric-collection#persistence-handlers--synchronization',

packages/query-db-collection/src/query.ts

Lines changed: 63 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { QueryObserver, hashKey } from '@tanstack/query-core'
2-
import { deepEquals } from '@tanstack/db'
2+
import { deepEquals, warnOnce } from '@tanstack/db'
33
import {
44
GetKeyRequiredError,
55
QueryClientRequiredError,
@@ -1251,23 +1251,31 @@ export function queryCollectionOptions(
12511251
? async (params: InsertMutationFnParams<any>) => {
12521252
const handlerResult = (await onInsert(params)) ?? {}
12531253

1254-
// Warn about deprecated return value pattern
1255-
if (
1254+
const explicitRefetchFalse =
12561255
handlerResult &&
12571256
typeof handlerResult === 'object' &&
1258-
Object.keys(handlerResult).length > 0
1259-
) {
1260-
console.warn(
1261-
'[TanStack DB] DEPRECATED: Returning values from mutation handlers is deprecated and will be removed in v1.0. ' +
1262-
'Use `await collection.utils.refetch()` instead of returning { refetch }. ' +
1263-
'See migration guide: https://tanstack.com/db/latest/docs/guides/mutations#collection-specific-handler-patterns',
1257+
'refetch' in handlerResult &&
1258+
handlerResult.refetch === false
1259+
1260+
if (explicitRefetchFalse) {
1261+
// User is correctly opting out of auto-refetch - warn about upcoming change
1262+
warnOnce(
1263+
'query-collection-refetch-false',
1264+
'[TanStack DB] Note: `return { refetch: false }` is the correct way to skip auto-refetch for now. ' +
1265+
'In v1.0, auto-refetch will be removed entirely and you should call `await collection.utils.refetch()` explicitly when needed, ' +
1266+
'or omit it to skip refetching. ' +
1267+
'See: https://tanstack.com/db/latest/docs/collections/query-collection#controlling-refetch-behavior',
1268+
)
1269+
} else {
1270+
// Auto-refetch is happening - warn about upcoming removal
1271+
warnOnce(
1272+
'query-collection-auto-refetch',
1273+
'[TanStack DB] DEPRECATED: QueryCollection handlers currently auto-refetch after completion. ' +
1274+
'This behavior will be removed in v1.0. To prepare: ' +
1275+
'(1) Add `await collection.utils.refetch()` to your handler if you need refetching, or ' +
1276+
'(2) Return `{ refetch: false }` to opt out now if you don\'t need it. ' +
1277+
'See: https://tanstack.com/db/latest/docs/collections/query-collection#controlling-refetch-behavior',
12641278
)
1265-
}
1266-
1267-
const shouldRefetch =
1268-
(handlerResult as { refetch?: boolean }).refetch !== false
1269-
1270-
if (shouldRefetch) {
12711279
await refetch()
12721280
}
12731281

@@ -1279,23 +1287,29 @@ export function queryCollectionOptions(
12791287
? async (params: UpdateMutationFnParams<any>) => {
12801288
const handlerResult = (await onUpdate(params)) ?? {}
12811289

1282-
// Warn about deprecated return value pattern
1283-
if (
1290+
const explicitRefetchFalse =
12841291
handlerResult &&
12851292
typeof handlerResult === 'object' &&
1286-
Object.keys(handlerResult).length > 0
1287-
) {
1288-
console.warn(
1289-
'[TanStack DB] DEPRECATED: Returning values from mutation handlers is deprecated and will be removed in v1.0. ' +
1290-
'Use `await collection.utils.refetch()` instead of returning { refetch }. ' +
1291-
'See migration guide: https://tanstack.com/db/latest/docs/guides/mutations#collection-specific-handler-patterns',
1293+
'refetch' in handlerResult &&
1294+
handlerResult.refetch === false
1295+
1296+
if (explicitRefetchFalse) {
1297+
warnOnce(
1298+
'query-collection-refetch-false',
1299+
'[TanStack DB] Note: `return { refetch: false }` is the correct way to skip auto-refetch for now. ' +
1300+
'In v1.0, auto-refetch will be removed entirely and you should call `await collection.utils.refetch()` explicitly when needed, ' +
1301+
'or omit it to skip refetching. ' +
1302+
'See: https://tanstack.com/db/latest/docs/collections/query-collection#controlling-refetch-behavior',
1303+
)
1304+
} else {
1305+
warnOnce(
1306+
'query-collection-auto-refetch',
1307+
'[TanStack DB] DEPRECATED: QueryCollection handlers currently auto-refetch after completion. ' +
1308+
'This behavior will be removed in v1.0. To prepare: ' +
1309+
'(1) Add `await collection.utils.refetch()` to your handler if you need refetching, or ' +
1310+
'(2) Return `{ refetch: false }` to opt out now if you don\'t need it. ' +
1311+
'See: https://tanstack.com/db/latest/docs/collections/query-collection#controlling-refetch-behavior',
12921312
)
1293-
}
1294-
1295-
const shouldRefetch =
1296-
(handlerResult as { refetch?: boolean }).refetch !== false
1297-
1298-
if (shouldRefetch) {
12991313
await refetch()
13001314
}
13011315

@@ -1307,23 +1321,29 @@ export function queryCollectionOptions(
13071321
? async (params: DeleteMutationFnParams<any>) => {
13081322
const handlerResult = (await onDelete(params)) ?? {}
13091323

1310-
// Warn about deprecated return value pattern
1311-
if (
1324+
const explicitRefetchFalse =
13121325
handlerResult &&
13131326
typeof handlerResult === 'object' &&
1314-
Object.keys(handlerResult).length > 0
1315-
) {
1316-
console.warn(
1317-
'[TanStack DB] DEPRECATED: Returning values from mutation handlers is deprecated and will be removed in v1.0. ' +
1318-
'Use `await collection.utils.refetch()` instead of returning { refetch }. ' +
1319-
'See migration guide: https://tanstack.com/db/latest/docs/guides/mutations#collection-specific-handler-patterns',
1327+
'refetch' in handlerResult &&
1328+
handlerResult.refetch === false
1329+
1330+
if (explicitRefetchFalse) {
1331+
warnOnce(
1332+
'query-collection-refetch-false',
1333+
'[TanStack DB] Note: `return { refetch: false }` is the correct way to skip auto-refetch for now. ' +
1334+
'In v1.0, auto-refetch will be removed entirely and you should call `await collection.utils.refetch()` explicitly when needed, ' +
1335+
'or omit it to skip refetching. ' +
1336+
'See: https://tanstack.com/db/latest/docs/collections/query-collection#controlling-refetch-behavior',
1337+
)
1338+
} else {
1339+
warnOnce(
1340+
'query-collection-auto-refetch',
1341+
'[TanStack DB] DEPRECATED: QueryCollection handlers currently auto-refetch after completion. ' +
1342+
'This behavior will be removed in v1.0. To prepare: ' +
1343+
'(1) Add `await collection.utils.refetch()` to your handler if you need refetching, or ' +
1344+
'(2) Return `{ refetch: false }` to opt out now if you don\'t need it. ' +
1345+
'See: https://tanstack.com/db/latest/docs/collections/query-collection#controlling-refetch-behavior',
13201346
)
1321-
}
1322-
1323-
const shouldRefetch =
1324-
(handlerResult as { refetch?: boolean }).refetch !== false
1325-
1326-
if (shouldRefetch) {
13271347
await refetch()
13281348
}
13291349

0 commit comments

Comments
 (0)