Proper Error Handling in React Query

❌ Wrong → Swallowing Errors
function useRepos() {
return useQuery({
queryKey: ['repos'],
queryFn: async () => {
try {
const response = await fetch('/api/repos')
if (!response.ok) throw new Error('Failed')
return response.json()
} catch (error) {
console.log('Error:', error) // ❌ Swallows the error!
// React Query never knows an error occurred
}
}
})
}
// Result: status stays 'success', no retries, broken error handling
✅ Correct → Let React Query Handle It
function useRepos() {
return useQuery({
queryKey: ['repos'],
queryFn: async () => {
const response = await fetch('/api/repos')
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`) // ✅ Throws to RQ
}
return response.json()
},
retry: 3, // Works because error propagates
retryDelay: 1000
})
}
Why This Breaks Everything
When you catch and don't re-throw:
No retries → React Query doesn't know it failed
status stays 'success' → Component thinks everything is fine
No error boundaries → Can't use global error handling
Silent failures → Users never know something went wrong
The Smart Error Handling Setup
const queryClient = new QueryClient({
defaultOptions: {
queries: {
throwOnError: (error, query) => {
// Only throw to Error Boundary if no cached data
return typeof query.state.data === 'undefined'
}
}
},
queryCache: new QueryCache({
onError: (error, query) => {
// Show toast if we have cached data (background refetch failed)
if (typeof query.state.data !== 'undefined') {
toast.error(`Background sync failed: ${error.message}`)
}
}
})
})
This pattern gives you:
Error Boundaries for initial load failures
Toast notifications for background failures
Automatic retries for transient issues
Global error handling without component coupling
The key insight: Let React Query own the error lifecycle, then hook into it where you need to.






