Repository: tannerlinsley/react-query
Pull request #1504 (2020-12-22 15:15:09)

feat: add error callback for queries and mutations
This PR adds global `onError` callbacks to `QueryCache` and `MutationCache`.

```js
const queryCache = new QueryCache({
  onError: error => {
    console.log(error)
  },
})

const mutationCache = new MutationCache({
  onError: error => {
    console.log(error)
  },
})
```

While it is possible to set `onError` in `defaultOptions`, it will not be called when a hook defines its own `onError`.

vercel[bot]: This pull request is being automatically deployed with Vercel ([learn more](https://vercel.link/github-learn-more)).  
To see the status of your deployment, click below or on the icon next to each commit.

🔍 Inspect: [https://vercel.com/tannerlinsley/react-query/evh470t6q](https://vercel.com/tannerlinsley/react-query/evh470t6q)  
✅ Preview: [https://react-query-git-feature-add-error-callbacks.tannerlinsley.vercel.app](https://react-query-git-feature-add-error-callbacks.tannerlinsley.vercel.app)



Patch:
diff --git a/docs/src/pages/reference/MutationCache.md b/docs/src/pages/reference/MutationCache.md
index da93a7306..3c3c79354 100644
--- a/docs/src/pages/reference/MutationCache.md
+++ b/docs/src/pages/reference/MutationCache.md
@@ -10,7 +10,11 @@ The `MutationCache` is the storage for mutations.
 ```js
 import { MutationCache } from 'react-query'
 
-const mutationCache = new MutationCache()
+const mutationCache = new MutationCache({
+  onError: error => {
+    console.log(error)
+  },
+})
 ```
 
 Its available methods are:
@@ -19,6 +23,12 @@ Its available methods are:
 - [`subscribe`](#mutationcachesubscribe)
 - [`clear`](#mutationcacheclear)
 
+**Options**
+
+- `onError?: (error: unknown, variables: unknown, context: unknown, mutation: Mutation) => void`
+  - Optional
+  - This function will be called if some mutation encounters an error.
+
 ## `mutationCache.getAll`
 
 `getAll` returns all mutations within the cache.
diff --git a/docs/src/pages/reference/QueryCache.md b/docs/src/pages/reference/QueryCache.md
index 632b5a4be..3e26d55c2 100644
--- a/docs/src/pages/reference/QueryCache.md
+++ b/docs/src/pages/reference/QueryCache.md
@@ -10,7 +10,12 @@ The `QueryCache` is the storage mechanism for React Query. It stores all of the
 ```js
 import { QueryCache } from 'react-query'
 
-const queryCache = new QueryCache()
+const queryCache = new QueryCache({
+  onError: error => {
+    console.log(error)
+  },
+})
+
 const query = queryCache.find('posts')
 ```
 
@@ -21,6 +26,12 @@ Its available methods are:
 - [`subscribe`](#querycachesubscribe)
 - [`clear`](#querycacheclear)
 
+**Options**
+
+- `onError?: (error: unknown, query: Query) => void`
+  - Optional
+  - This function will be called if some query encounters an error.
+
 ## `queryCache.find`
 
 `find` is a slightly more advanced synchronous method that can be used to get an existing query instance from the cache. This instance not only contains **all** the state for the query, but all of the instances, and underlying guts of the query as well. If the query does not exist, `undefined` will be returned.
diff --git a/package.json b/package.json
index 112303a66..f2ea788ea 100644
--- a/package.json
+++ b/package.json
@@ -32,8 +32,13 @@
     "build:es": "rimraf ./es && babel --extensions .ts,.tsx --ignore ./src/**/tests/**/* ./src --out-dir es",
     "build:umd": "rimraf ./dist && cross-env NODE_ENV=production rollup -c && rollup-plugin-visualizer stats-react.json",
     "build:types": "rimraf ./types && tsc --project ./tsconfig.types.json && replace 'import type' 'import' ./types -r --silent && replace 'export type' 'export' ./types -r --silent",
+    "watch": "yarn watch:commonjs & yarn watch:es & yarn watch:umd & yarn watch:types",
+    "watch:commonjs": "rimraf ./lib && cross-env BABEL_ENV=commonjs babel --watch --extensions .ts,.tsx --ignore ./src/**/tests/**/* ./src --out-dir lib",
+    "watch:es": "rimraf ./es && babel --watch --extensions .ts,.tsx --ignore ./src/**/tests/**/* ./src --out-dir es",
+    "watch:umd": "rimraf ./dist && cross-env NODE_ENV=production rollup -w -c && rollup-plugin-visualizer stats-react.json",
+    "watch:types": "rimraf ./types && tsc --watch --project ./tsconfig.types.json && replace 'import type' 'import' ./types -r --silent && replace 'export type' 'export' ./types -r --silent",
     "now-build": "yarn && cd www && yarn && yarn build",
-    "start": "rollup -c -w",
+    "start": "yarn watch",
     "format": "prettier {.,src,src/**,example/src,example/src/**,types}/*.{md,js,jsx,tsx,json} --write",
     "stats": "open ./stats.html"
   },
diff --git a/src/core/mutation.ts b/src/core/mutation.ts
index 5b7b66203..90fe3cb37 100644
--- a/src/core/mutation.ts
+++ b/src/core/mutation.ts
@@ -177,7 +177,19 @@ export class Mutation<
         return data
       })
       .catch(error => {
+        // Notify cache callback
+        if (this.mutationCache.config.onError) {
+          this.mutationCache.config.onError(
+            error,
+            this.state.variables,
+            this.state.context,
+            this as Mutation<unknown, unknown, unknown, unknown>
+          )
+        }
+
+        // Log error
         getLogger().error(error)
+
         return Promise.resolve()
           .then(() =>
             this.options.onError?.(
diff --git a/src/core/mutationCache.ts b/src/core/mutationCache.ts
index 61aedb53d..1ed431750 100644
--- a/src/core/mutationCache.ts
+++ b/src/core/mutationCache.ts
@@ -7,16 +7,28 @@ import { Subscribable } from './subscribable'
 
 // TYPES
 
+interface MutationCacheConfig {
+  onError?: (
+    error: unknown,
+    variables: unknown,
+    context: unknown,
+    mutation: Mutation<unknown, unknown, unknown, unknown>
+  ) => void
+}
+
 type MutationCacheListener = (mutation?: Mutation) => void
 
 // CLASS
 
 export class MutationCache extends Subscribable<MutationCacheListener> {
+  config: MutationCacheConfig
+
   private mutations: Mutation<any, any, any, any>[]
   private mutationId: number
 
-  constructor() {
+  constructor(config?: MutationCacheConfig) {
     super()
+    this.config = config || {}
     this.mutations = []
     this.mutationId = 0
   }
diff --git a/src/core/query.ts b/src/core/query.ts
index 310730442..f832748d9 100644
--- a/src/core/query.ts
+++ b/src/core/query.ts
@@ -412,10 +412,16 @@ export class Query<
           })
         }
 
-        // Log error
         if (!isCancelledError(error)) {
+          // Notify cache callback
+          if (this.cache.config.onError) {
+            this.cache.config.onError(error, this as Query)
+          }
+
+          // Log error
           getLogger().error(error)
         }
+
         // Remove query after fetching if cache time is 0
         if (this.cacheTime === 0) {
           this.optionalRemove()
@@ -423,15 +429,15 @@ export class Query<
 
         // Propagate error
         throw error
-      }).then(
-          promise => {
-            if (this.cacheTime === 0) {
-              this.optionalRemove()
-            }
-            return promise
-          }
-      )
+      })
+      .then(data => {
+        // Remove query after fetching if cache time is 0
+        if (this.cacheTime === 0) {
+          this.optionalRemove()
+        }
 
+        return data
+      })
 
     return this.promise
   }
diff --git a/src/core/queryCache.ts b/src/core/queryCache.ts
index f6373d38d..5c4829535 100644
--- a/src/core/queryCache.ts
+++ b/src/core/queryCache.ts
@@ -12,6 +12,10 @@ import { Subscribable } from './subscribable'
 
 // TYPES
 
+interface QueryCacheConfig {
+  onError?: (error: unknown, query: Query<unknown, unknown, unknown>) => void
+}
+
 interface QueryHashMap {
   [hash: string]: Query<any, any>
 }
@@ -21,12 +25,14 @@ type QueryCacheListener = (query?: Query) => void
 // CLASS
 
 export class QueryCache extends Subscribable<QueryCacheListener> {
+  config: QueryCacheConfig
+
   private queries: Query<any, any>[]
   private queriesMap: QueryHashMap
 
-  constructor() {
+  constructor(config?: QueryCacheConfig) {
     super()
-
+    this.config = config || {}
     this.queries = []
     this.queriesMap = {}
   }
diff --git a/src/devtools/devtools.tsx b/src/devtools/devtools.tsx
index 668ecc8fc..5561021d4 100644
--- a/src/devtools/devtools.tsx
+++ b/src/devtools/devtools.tsx
@@ -322,7 +322,7 @@ export const ReactQueryDevtoolsPanel = React.forwardRef(
     }
 
     const [unsortedQueries, setUnsortedQueries] = useSafeState(
-      Object.values(queryCache.getAll())
+      Object.values(queryCache.findAll())
     )
 
     const [activeQueryHash, setActiveQueryHash] = useLocalStorage(
@@ -655,13 +655,29 @@ export const ReactQueryDevtoolsPanel = React.forwardRef(
                   Refetch
                 </Button>{' '}
                 <Button
-                  onClick={() => queryCache.remove(activeQuery)}
+                  onClick={() => queryClient.invalidateQueries(activeQuery)}
+                  style={{
+                    background: theme.warning,
+                  }}
+                >
+                  Invalidate
+                </Button>{' '}
+                <Button
+                  onClick={() => queryClient.resetQueries(activeQuery)}
+                  style={{
+                    background: theme.gray,
+                  }}
+                >
+                  Reset
+                </Button>{' '}
+                <Button
+                  onClick={() => queryClient.removeQueries(activeQuery)}
                   style={{
                     background: theme.danger,
                   }}
                 >
                   Remove
-                </Button>{' '}
+                </Button>
               </div>
               <div
                 style={{


Test patch:
diff --git a/src/core/tests/mutationCache.test.tsx b/src/core/tests/mutationCache.test.tsx
new file mode 100644
index 000000000..9705b2228
--- /dev/null
+++ b/src/core/tests/mutationCache.test.tsx
@@ -0,0 +1,28 @@
+import { queryKey, mockConsoleError } from '../../react/tests/utils'
+import { MutationCache, QueryClient } from '../..'
+
+describe('mutationCache', () => {
+  describe('MutationCacheConfig.onError', () => {
+    test('should be called when a mutation errors', async () => {
+      const consoleMock = mockConsoleError()
+      const key = queryKey()
+      const onError = jest.fn()
+      const testCache = new MutationCache({ onError })
+      const testClient = new QueryClient({ mutationCache: testCache })
+
+      try {
+        await testClient.executeMutation({
+          mutationKey: key,
+          variables: 'vars',
+          mutationFn: () => Promise.reject('error'),
+          onMutate: () => 'context',
+        })
+      } catch {
+        consoleMock.mockRestore()
+      }
+
+      const mutation = testCache.getAll()[0]
+      expect(onError).toHaveBeenCalledWith('error', 'vars', 'context', mutation)
+    })
+  })
+})
diff --git a/src/core/tests/queryCache.test.tsx b/src/core/tests/queryCache.test.tsx
index b7f140782..179d016f9 100644
--- a/src/core/tests/queryCache.test.tsx
+++ b/src/core/tests/queryCache.test.tsx
@@ -1,4 +1,4 @@
-import { sleep, queryKey } from '../../react/tests/utils'
+import { sleep, queryKey, mockConsoleError } from '../../react/tests/utils'
 import { QueryCache, QueryClient } from '../..'
 
 describe('queryCache', () => {
@@ -130,4 +130,18 @@ describe('queryCache', () => {
       expect(queryCache.findAll('posts')).toEqual([query4])
     })
   })
+
+  describe('QueryCacheConfig.onError', () => {
+    test('should be called when a query errors', async () => {
+      const consoleMock = mockConsoleError()
+      const key = queryKey()
+      const onError = jest.fn()
+      const testCache = new QueryCache({ onError })
+      const testClient = new QueryClient({ queryCache: testCache })
+      await testClient.prefetchQuery(key, () => Promise.reject('error'))
+      consoleMock.mockRestore()
+      const query = testCache.find(key)
+      expect(onError).toHaveBeenCalledWith('error', query)
+    })
+  })
 })