Skip to content
5 changes: 5 additions & 0 deletions .changeset/clear-days-raise.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tanstack/query-db-collection": patch
---

Add error tracking and retry methods to query collection utils.
47 changes: 47 additions & 0 deletions docs/guides/error-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,52 @@ The error includes:
- `issues`: Array of validation issues with messages and paths
- `message`: A formatted error message listing all issues

## Query Collection Error Tracking

Query collections provide enhanced error tracking utilities through the `utils` object. These methods expose error state information and provide recovery mechanisms for failed queries:

```tsx
import { createCollection } from "@tanstack/db"
import { queryCollectionOptions } from "@tanstack/query-db-collection"
import { useLiveQuery } from "@tanstack/react-db"

const syncedCollection = createCollection(
queryCollectionOptions({
queryClient,
queryKey: ['synced-data'],
queryFn: fetchData,
getKey: (item) => item.id,
})
)

// Component can check error state
function DataList() {
const { data } = useLiveQuery((q) => q.from({ item: syncedCollection }))
const isError = syncedCollection.utils.isError()
const errorCount = syncedCollection.utils.errorCount()

return (
<>
{isError && errorCount > 3 && (
<Alert>
Unable to sync. Showing cached data.
<button onClick={() => syncedCollection.utils.clearError()}>
Retry
</button>
</Alert>
)}
{/* Render data */}
</>
)
}
```

Error tracking methods:
- **`lastError()`**: Returns the most recent error encountered by the query, or `undefined` if no errors have occurred:
- **`isError()`**: Returns a boolean indicating whether the collection is currently in an error state:
- **`errorCount()`**: Returns the number of consecutive sync failures. This counter is incremented only when queries fail completely (not per retry attempt) and is reset on successful queries:
- **`clearError()`**: Clears the error state and triggers a refetch of the query. This method resets both `lastError` and `errorCount`:

## Collection Status and Error States

Collections track their status and transition between states:
Expand Down Expand Up @@ -281,6 +327,7 @@ When sync errors occur:
- Error is logged to console: `[QueryCollection] Error observing query...`
- Collection is marked as ready to prevent blocking the application
- Cached data remains available
- Error tracking counters are updated (`lastError`, `errorCount`)

### Sync Write Errors

Expand Down
61 changes: 55 additions & 6 deletions packages/query-db-collection/src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,19 +301,21 @@ export interface QueryCollectionConfig<
/**
* Type for the refetch utility function
*/
export type RefetchFn = () => Promise<void>
export type RefetchFn = (opts?: { throwOnError?: boolean }) => Promise<void>

/**
* Utility methods available on Query Collections for direct writes and manual operations.
* Direct writes bypass the normal query/mutation flow and write directly to the synced data store.
* @template TItem - The type of items stored in the collection
* @template TKey - The type of the item keys
* @template TInsertInput - The type accepted for insert operations
* @template TError - The type of errors that can occur during queries
*/
export interface QueryCollectionUtils<
TItem extends object = Record<string, unknown>,
TKey extends string | number = string | number,
TInsertInput extends object = TItem,
TError = unknown,
> extends UtilsRecord {
/** Manually trigger a refetch of the query */
refetch: RefetchFn
Expand All @@ -327,6 +329,21 @@ export interface QueryCollectionUtils<
writeUpsert: (data: Partial<TItem> | Array<Partial<TItem>>) => void
/** Execute multiple write operations as a single atomic batch to the synced data store */
writeBatch: (callback: () => void) => void
/** Get the last error encountered by the query (if any); reset on success */
lastError: () => TError | undefined
/** Check if the collection is in an error state */
isError: () => boolean
/**
* Get the number of consecutive sync failures.
* Incremented only when query fails completely (not per retry attempt); reset on success.
*/
errorCount: () => number
/**
* Clear the error state and trigger a refetch of the query
* @returns Promise that resolves when the refetch completes successfully
* @throws Error if the refetch fails
*/
clearError: () => Promise<void>
}

/**
Expand Down Expand Up @@ -424,7 +441,8 @@ export function queryCollectionOptions<
utils: QueryCollectionUtils<
ResolveType<TExplicit, TSchema, TQueryFn>,
TKey,
TInsertInput
TInsertInput,
TError
>
} {
type TItem = ResolveType<TExplicit, TSchema, TQueryFn>
Expand Down Expand Up @@ -467,6 +485,13 @@ export function queryCollectionOptions<
throw new GetKeyRequiredError()
}

/** The last error encountered by the query */
let lastError: TError | undefined
/** The number of consecutive sync failures */
let errorCount = 0
/** The timestamp for when the query most recently returned the status as "error" */
let lastErrorUpdatedAt = 0

const internalSync: SyncConfig<TItem>[`sync`] = (params) => {
const { begin, write, commit, markReady, collection } = params

Expand Down Expand Up @@ -500,6 +525,10 @@ export function queryCollectionOptions<
type UpdateHandler = Parameters<typeof localObserver.subscribe>[0]
const handleUpdate: UpdateHandler = (result) => {
if (result.isSuccess) {
// Clear error state
lastError = undefined
errorCount = 0

const newItemsArray = result.data

if (
Expand Down Expand Up @@ -568,6 +597,12 @@ export function queryCollectionOptions<
// Mark collection as ready after first successful query result
markReady()
} else if (result.isError) {
if (result.errorUpdatedAt !== lastErrorUpdatedAt) {
lastError = result.error
errorCount++
lastErrorUpdatedAt = result.errorUpdatedAt
}

console.error(
`[QueryCollection] Error observing query ${String(queryKey)}:`,
result.error
Expand Down Expand Up @@ -595,10 +630,15 @@ export function queryCollectionOptions<
* Refetch the query data
* @returns Promise that resolves when the refetch is complete
*/
const refetch: RefetchFn = async (): Promise<void> => {
return queryClient.refetchQueries({
queryKey: queryKey,
})
const refetch: RefetchFn = (opts) => {
return queryClient.refetchQueries(
{
queryKey: queryKey,
},
{
throwOnError: opts?.throwOnError,
}
)
}

// Create write context for manual write operations
Expand Down Expand Up @@ -689,6 +729,15 @@ export function queryCollectionOptions<
utils: {
refetch,
...writeUtils,
lastError: () => lastError,
isError: () => !!lastError,
errorCount: () => errorCount,
clearError: () => {
lastError = undefined
errorCount = 0
lastErrorUpdatedAt = 0
return refetch({ throwOnError: true })
},
},
}
}
Loading
Loading