diff --git a/.changeset/clear-days-raise.md b/.changeset/clear-days-raise.md
new file mode 100644
index 00000000..82bdceba
--- /dev/null
+++ b/.changeset/clear-days-raise.md
@@ -0,0 +1,5 @@
+---
+"@tanstack/query-db-collection": patch
+---
+
+Add error tracking and retry methods to query collection utils.
diff --git a/docs/guides/error-handling.md b/docs/guides/error-handling.md
index 2ab15e13..7661c240 100644
--- a/docs/guides/error-handling.md
+++ b/docs/guides/error-handling.md
@@ -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 && (
+
+ Unable to sync. Showing cached data.
+
+
+ )}
+ {/* 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:
@@ -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
diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts
index 23b58c98..73e71b4f 100644
--- a/packages/query-db-collection/src/query.ts
+++ b/packages/query-db-collection/src/query.ts
@@ -301,7 +301,7 @@ export interface QueryCollectionConfig<
/**
* Type for the refetch utility function
*/
-export type RefetchFn = () => Promise
+export type RefetchFn = (opts?: { throwOnError?: boolean }) => Promise
/**
* Utility methods available on Query Collections for direct writes and manual operations.
@@ -309,11 +309,13 @@ export type RefetchFn = () => Promise
* @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,
TKey extends string | number = string | number,
TInsertInput extends object = TItem,
+ TError = unknown,
> extends UtilsRecord {
/** Manually trigger a refetch of the query */
refetch: RefetchFn
@@ -327,6 +329,21 @@ export interface QueryCollectionUtils<
writeUpsert: (data: Partial | Array>) => 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
}
/**
@@ -424,7 +441,8 @@ export function queryCollectionOptions<
utils: QueryCollectionUtils<
ResolveType,
TKey,
- TInsertInput
+ TInsertInput,
+ TError
>
} {
type TItem = ResolveType
@@ -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[`sync`] = (params) => {
const { begin, write, commit, markReady, collection } = params
@@ -500,6 +525,10 @@ export function queryCollectionOptions<
type UpdateHandler = Parameters[0]
const handleUpdate: UpdateHandler = (result) => {
if (result.isSuccess) {
+ // Clear error state
+ lastError = undefined
+ errorCount = 0
+
const newItemsArray = result.data
if (
@@ -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
@@ -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 => {
- return queryClient.refetchQueries({
- queryKey: queryKey,
- })
+ const refetch: RefetchFn = (opts) => {
+ return queryClient.refetchQueries(
+ {
+ queryKey: queryKey,
+ },
+ {
+ throwOnError: opts?.throwOnError,
+ }
+ )
}
// Create write context for manual write operations
@@ -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 })
+ },
},
}
}
diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts
index ecb82480..47a14a70 100644
--- a/packages/query-db-collection/tests/query.test.ts
+++ b/packages/query-db-collection/tests/query.test.ts
@@ -1632,4 +1632,318 @@ describe(`QueryCollection`, () => {
expect.arrayContaining(initialItems)
)
})
+
+ describe(`Error Handling`, () => {
+ // Helper to create test collection with common configuration
+ const createErrorHandlingTestCollection = (
+ testId: string,
+ queryFn: ReturnType
+ ) => {
+ const config: QueryCollectionConfig = {
+ id: testId,
+ queryClient,
+ queryKey: [testId],
+ queryFn,
+ getKey,
+ startSync: true,
+ retry: false,
+ }
+ const options = queryCollectionOptions(config)
+ return createCollection(options)
+ }
+
+ it(`should track error state, count, and support recovery`, async () => {
+ const initialData = [{ id: `1`, name: `Item 1` }]
+ const updatedData = [{ id: `1`, name: `Updated Item 1` }]
+ const errors = [new Error(`First error`), new Error(`Second error`)]
+
+ const queryFn = vi
+ .fn()
+ .mockResolvedValueOnce(initialData) // Initial success
+ .mockRejectedValueOnce(errors[0]) // First error
+ .mockRejectedValueOnce(errors[1]) // Second error
+ .mockResolvedValueOnce(updatedData) // Recovery
+
+ const collection = createErrorHandlingTestCollection(
+ `error-tracking-test`,
+ queryFn
+ )
+
+ // Wait for initial success - no errors
+ await vi.waitFor(() => {
+ expect(collection.status).toBe(`ready`)
+ expect(collection.utils.lastError()).toBeUndefined()
+ expect(collection.utils.isError()).toBe(false)
+ expect(collection.utils.errorCount()).toBe(0)
+ })
+
+ // First error - count increments
+ await collection.utils.refetch()
+ await vi.waitFor(() => {
+ expect(collection.utils.lastError()).toBe(errors[0])
+ expect(collection.utils.errorCount()).toBe(1)
+ expect(collection.utils.isError()).toBe(true)
+ })
+
+ // Second error - count increments again
+ await collection.utils.refetch()
+ await vi.waitFor(() => {
+ expect(collection.utils.lastError()).toBe(errors[1])
+ expect(collection.utils.errorCount()).toBe(2)
+ expect(collection.utils.isError()).toBe(true)
+ })
+
+ // Successful refetch resets error state
+ await collection.utils.refetch()
+ await vi.waitFor(() => {
+ expect(collection.utils.lastError()).toBeUndefined()
+ expect(collection.utils.isError()).toBe(false)
+ expect(collection.utils.errorCount()).toBe(0)
+ expect(collection.get(`1`)).toEqual(updatedData[0])
+ })
+ })
+
+ it(`should support manual error recovery with clearError`, async () => {
+ const recoveryData = [{ id: `1`, name: `Item 1` }]
+ const testError = new Error(`Test error`)
+
+ const queryFn = vi
+ .fn()
+ .mockRejectedValueOnce(testError)
+ .mockResolvedValueOnce(recoveryData)
+ .mockRejectedValueOnce(testError)
+
+ const collection = createErrorHandlingTestCollection(
+ `clear-error-test`,
+ queryFn
+ )
+
+ // Wait for initial error
+ await vi.waitFor(() => {
+ expect(collection.utils.isError()).toBe(true)
+ expect(collection.utils.errorCount()).toBe(1)
+ })
+
+ // Manual error clearing triggers refetch
+ await collection.utils.clearError()
+
+ expect(collection.utils.lastError()).toBeUndefined()
+ expect(collection.utils.isError()).toBe(false)
+ expect(collection.utils.errorCount()).toBe(0)
+
+ await vi.waitFor(() => {
+ expect(collection.get(`1`)).toEqual(recoveryData[0])
+ })
+
+ // Refetch on rejection should throw an error
+ await expect(collection.utils.clearError()).rejects.toThrow(testError)
+ expect(collection.utils.lastError()).toBe(testError)
+ expect(collection.utils.isError()).toBe(true)
+ expect(collection.utils.errorCount()).toBe(1)
+ })
+
+ it(`should maintain collection functionality despite errors and persist error state`, async () => {
+ const initialData = [
+ { id: `1`, name: `Item 1` },
+ { id: `2`, name: `Item 2` },
+ ]
+ const testError = new Error(`Query error`)
+
+ const queryFn = vi
+ .fn()
+ .mockResolvedValueOnce(initialData)
+ .mockRejectedValue(testError)
+
+ const collection = createErrorHandlingTestCollection(
+ `functionality-with-errors-test`,
+ queryFn
+ )
+
+ await vi.waitFor(() => {
+ expect(collection.status).toBe(`ready`)
+ expect(collection.size).toBe(2)
+ })
+
+ // Cause error
+ await collection.utils.refetch()
+ await vi.waitFor(() => {
+ expect(collection.utils.errorCount()).toBe(1)
+ expect(collection.utils.isError()).toBe(true)
+ })
+
+ // Collection operations still work with cached data
+ expect(collection.size).toBe(2)
+ expect(collection.get(`1`)).toEqual(initialData[0])
+ expect(collection.get(`2`)).toEqual(initialData[1])
+
+ // Manual write operations work and clear error state
+ const newItem = { id: `3`, name: `Manual Item` }
+ collection.utils.writeInsert(newItem)
+ expect(collection.size).toBe(3)
+ expect(collection.get(`3`)).toEqual(newItem)
+
+ await flushPromises()
+
+ // Manual writes clear error state
+ expect(collection.utils.lastError()).toBeUndefined()
+ expect(collection.utils.isError()).toBe(false)
+ expect(collection.utils.errorCount()).toBe(0)
+
+ // Create error state again for persistence test
+ await collection.utils.refetch()
+ await vi.waitFor(() => expect(collection.utils.isError()).toBe(true))
+
+ const originalError = collection.utils.lastError()
+ const originalErrorCount = collection.utils.errorCount()
+
+ // Read-only operations don't affect error state
+ expect(collection.has(`1`)).toBe(true)
+ const changeHandler = vi.fn()
+ const unsubscribe = collection.subscribeChanges(changeHandler)
+
+ expect(collection.utils.lastError()).toBe(originalError)
+ expect(collection.utils.isError()).toBe(true)
+ expect(collection.utils.errorCount()).toBe(originalErrorCount)
+
+ unsubscribe()
+ })
+
+ it(`should handle custom error objects correctly`, async () => {
+ interface CustomError {
+ code: string
+ message: string
+ details?: Record
+ }
+ const customError: CustomError = {
+ code: `NETWORK_ERROR`,
+ message: `Failed to fetch data`,
+ details: { retryAfter: 5000 },
+ }
+
+ // Start with error immediately - no initial success needed
+ const queryFn = vi.fn().mockRejectedValue(customError)
+
+ const config: QueryCollectionConfig<
+ TestItem,
+ never,
+ typeof queryFn,
+ CustomError
+ > = {
+ id: `custom-error-test`,
+ queryClient,
+ queryKey: [`custom-error-test`],
+ queryFn,
+ getKey,
+ startSync: true,
+ retry: false,
+ }
+
+ const options = queryCollectionOptions(config)
+ const collection = createCollection(options)
+
+ // Wait for collection to be ready (even with error)
+ await vi.waitFor(() => {
+ expect(collection.status).toBe(`ready`)
+ expect(collection.utils.isError()).toBe(true)
+ })
+
+ // Verify custom error is accessible with all its properties
+ const lastError = collection.utils.lastError()
+ expect(lastError).toBe(customError)
+ expect(lastError?.code).toBe(`NETWORK_ERROR`)
+ expect(lastError?.message).toBe(`Failed to fetch data`)
+ expect(lastError?.details?.retryAfter).toBe(5000)
+ expect(collection.utils.errorCount()).toBe(1)
+ })
+
+ it(`should persist error state after collection cleanup`, async () => {
+ const testError = new Error(`Persistent error`)
+
+ // Start with error immediately
+ const queryFn = vi.fn().mockRejectedValue(testError)
+
+ const collection = createErrorHandlingTestCollection(
+ `error-persistence-cleanup-test`,
+ queryFn
+ )
+
+ // Wait for collection to be ready (even with error)
+ await vi.waitFor(() => {
+ expect(collection.status).toBe(`ready`)
+ expect(collection.utils.isError()).toBe(true)
+ })
+
+ // Verify error state before cleanup
+ expect(collection.utils.lastError()).toBe(testError)
+ expect(collection.utils.errorCount()).toBe(1)
+
+ // Cleanup collection
+ await collection.cleanup()
+ expect(collection.status).toBe(`cleaned-up`)
+
+ // Error state should persist after cleanup
+ expect(collection.utils.isError()).toBe(true)
+ expect(collection.utils.lastError()).toBe(testError)
+ expect(collection.utils.errorCount()).toBe(1)
+ })
+
+ it(`should increment errorCount only after final failure when using Query retries`, async () => {
+ const testError = new Error(`Retry test error`)
+ const retryCount = 2
+ const totalAttempts = retryCount + 1
+
+ // Create a queryFn that fails consistently
+ const queryFn = vi.fn().mockRejectedValue(testError)
+
+ // Create collection with retry enabled (2 retries = 3 total attempts)
+ const config: QueryCollectionConfig = {
+ id: `retry-semantics-test`,
+ queryClient,
+ queryKey: [`retry-semantics-test`],
+ queryFn,
+ getKey,
+ startSync: true,
+ retry: retryCount, // This will result in 3 total attempts (initial + 2 retries)
+ retryDelay: 5, // Short delay for faster tests
+ }
+
+ const options = queryCollectionOptions(config)
+ const collection = createCollection(options)
+
+ // Wait for all retry attempts to complete and final failure
+ await vi.waitFor(
+ () => {
+ expect(collection.status).toBe(`ready`) // Should be ready even with error
+ expect(queryFn).toHaveBeenCalledTimes(totalAttempts)
+ expect(collection.utils.isError()).toBe(true)
+ },
+ { timeout: 2000 }
+ )
+
+ // Error count should only increment once after all retries are exhausted
+ // This ensures we track "consecutive post-retry failures," not per-attempt failures
+ expect(collection.utils.errorCount()).toBe(1)
+ expect(collection.utils.lastError()).toBe(testError)
+ expect(collection.utils.isError()).toBe(true)
+
+ // Reset attempt counter for second test
+ queryFn.mockClear()
+
+ // Trigger another refetch which should also retry and fail
+ await collection.utils.refetch()
+
+ // Wait for the second set of retries to complete
+ await vi.waitFor(
+ () => {
+ expect(queryFn).toHaveBeenCalledTimes(totalAttempts)
+ },
+ { timeout: 2000 }
+ )
+
+ // Error count should now be 2 (two post-retry failures)
+ expect(collection.utils.errorCount()).toBe(2)
+ expect(collection.utils.lastError()).toBe(testError)
+ expect(collection.utils.isError()).toBe(true)
+ })
+ })
})