Skip to content

Enhanced Error Handling and Reporting for query-db-collection #347

@KyleAMathews

Description

@KyleAMathews

Background

TanStack Query provides robust error handling with retry logic, error callbacks, and error boundaries. While query-db-collection supports basic retry configuration, it doesn't fully expose Query's error handling capabilities. Currently, errors are logged to console and the collection is marked as ready, but the error details aren't easily accessible.

Current Behavior

From query.ts:407-415:

} else if (result.isError) {
  console.error(
    `[QueryCollection] Error observing query ${String(queryKey)}:`,
    result.error
  )
  
  // Mark collection as ready even on error to avoid blocking apps
  markReady()
}

Problems

  1. Limited error visibility: Errors only go to console.error
  2. No error callbacks: Can't run custom logic on errors
  3. Missing error state in utils: No way to check last error
  4. No error boundary integration: Can't use React error boundaries

Proposed Solution

Enhance error handling to match TanStack Query's capabilities while respecting TanStack DB's design:

export interface QueryCollectionConfig<TItem, TError, TQueryKey> {
  // ... existing options ...
  
  /**
   * Callback when query encounters an error (after all retries)
   * @param error - The error that occurred
   * @param query - The query instance
   */
  onError?: (error: TError, query: Query) => void
  
  /**
   * Callback when query settles (success or error)
   * @param data - The data if successful
   * @param error - The error if failed
   */
  onSettled?: (data?: Array<TItem>, error?: TError) => void
  
  /**
   * Whether errors should be thrown to nearest error boundary
   * @default false
   */
  useErrorBoundary?: boolean | ((error: TError, query: Query) => boolean)
}

export interface QueryCollectionUtils<TItem, TKey, TInsertInput> {
  // ... existing utils ...
  
  /**
   * Get the last error encountered by the query (if any)
   */
  lastError: () => TError | undefined
  
  /**
   * Check if the collection is in an error state
   */
  isError: () => boolean
  
  /**
   * Get the number of consecutive sync failures
   */
  errorCount: () => number
  
  /**
   * Clear the error state and retry
   */
  clearError: () => Promise<void>
}

Implementation Examples

Example 1: Error Notification

const todoCollection = createCollection(
  queryCollectionOptions({
    queryKey: ['todos'],
    queryFn: fetchTodos,
    getKey: (item) => item.id,
    
    // Show toast on error
    onError: (error) => {
      toast.error(`Failed to load todos: ${error.message}`)
    },
    
    // Log to monitoring service
    onSettled: (data, error) => {
      if (error) {
        errorReporter.log(error, { collection: 'todos' })
      }
    },
    
    queryClient,
  })
)

// In component
const { data } = useLiveQuery((q) => 
  q.from({ todo: todoCollection })
)

const error = todoCollection.utils.lastError()
if (error) {
  return <ErrorMessage error={error} />
}

Example 2: Error Boundary Integration

const criticalDataCollection = createCollection(
  queryCollectionOptions({
    queryKey: ['critical-data'],
    queryFn: fetchCriticalData,
    getKey: (item) => item.id,
    
    // Throw to error boundary for critical failures
    useErrorBoundary: (error) => {
      // Only throw for non-network errors
      return error.code \!== 'NETWORK_ERROR'
    },
    
    queryClient,
  })
)

// In React component with error boundary
function App() {
  return (
    <ErrorBoundary fallback={<CriticalError />}>
      <CriticalDataView />
    </ErrorBoundary>
  )
}

Example 3: Offline-Aware Error Handling

const syncedCollection = createCollection(
  queryCollectionOptions({
    queryKey: ['synced-data'],
    queryFn: fetchData,
    getKey: (item) => item.id,
    
    onError: (error, query) => {
      // Check if we have stale data to show
      const hasStaleData = syncedCollection.size > 0
      
      if (navigator.onLine && \!hasStaleData) {
        // Online but failed - serious error
        toast.error('Failed to load data')
      } else if (\!navigator.onLine && hasStaleData) {
        // Offline but have cached data
        toast.info('Showing offline data')
      }
    },
    
    queryClient,
  })
)

// 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 */}
    </>
  )
}

Technical Implementation

Update the observer subscription to track errors:

// In queryCollectionOptions
let lastError: TError | undefined
let errorCount = 0

const actualUnsubscribeFn = localObserver.subscribe((result) => {
  if (result.isSuccess) {
    // Clear error state on success
    lastError = undefined
    errorCount = 0
    
    // ... existing success logic ...
    
    // Call onSettled
    config.onSettled?.(result.data, undefined)
  } else if (result.isError) {
    lastError = result.error
    errorCount++
    
    // Call error callback
    config.onError?.(result.error, localObserver.getCurrentQuery())
    
    // Call onSettled
    config.onSettled?.(undefined, result.error)
    
    // Handle error boundary
    if (config.useErrorBoundary) {
      const shouldThrow = typeof config.useErrorBoundary === 'function'
        ? config.useErrorBoundary(result.error, localObserver.getCurrentQuery())
        : config.useErrorBoundary
      
      if (shouldThrow) {
        throw result.error
      }
    }
    
    // Still mark ready to avoid blocking
    markReady()
  }
})

// Add to utils
const utils = {
  refetch,
  ...writeUtils,
  lastError: () => lastError,
  isError: () => \!\!lastError,
  errorCount: () => errorCount,
  clearError: async () => {
    lastError = undefined
    errorCount = 0
    return refetch()
  }
}

Benefits

  1. Better error visibility: Errors accessible via utils, not just console
  2. Custom error handling: Run app-specific logic on errors
  3. Error boundary support: Integrate with React error boundaries
  4. Offline resilience: Build UIs that gracefully handle sync failures
  5. Monitoring integration: Easy to add error tracking

Design Considerations

  • Data persistence: Even with errors, preserve cached data when available
  • Don't clear data on error: Keep the collection state intact
  • Progressive enhancement: All error features are optional
  • Retry friendly: clearError() utility makes retry UX simple

Testing Requirements

  1. Test onError callback is called with correct error
  2. Test onSettled is called for both success and error
  3. Test error boundary integration
  4. Test error state is accessible via utils functions
  5. Test clearError resets state and triggers refetch
  6. Test that collection still works with cached data despite errors

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions