Repository: tannerlinsley/react-query
Pull request #1971 (2021-03-15 15:03:15)

feat: add useIsMutating hook
This PR adds a useIsMutating hook as discussed in #1298.

Let me know if it needs any changes.

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/C6Qq9Dr3SHcB6WxfEaPnvNLJB4wR](https://vercel.com/tannerlinsley/react-query/C6Qq9Dr3SHcB6WxfEaPnvNLJB4wR)  
✅ Preview: *In Progress*


benediktviebahn: Any idea why the tests are failing? I saw some lint errors in VS Code but I assumed I had something incorrectly set up.
TkDodo: looks like a cyclic import:

```
/home/runner/work/react-query/react-query/src/core/retryer.ts
Error:   1:1  error  Dependency cycle via ./utils:2=>./mutation:1  import/no-cycle
Error:   2:1  error  Dependency cycle via ./utils:2=>./mutation:1  import/no-cycle
Error:   3:1  error  Dependency cycle via ./mutation:1             import/no-cycle
```
benediktviebahn: Oh right. Got it.

Patch:
diff --git a/docs/src/manifests/manifest.json b/docs/src/manifests/manifest.json
index 9d436295d..6b9e6b0bc 100644
--- a/docs/src/manifests/manifest.json
+++ b/docs/src/manifests/manifest.json
@@ -166,9 +166,9 @@
           "editUrl": "/guides/scroll-restoration.md"
         },
         {
-          "title": "Query Filters",
-          "path": "/guides/query-filters",
-          "editUrl": "/guides/query-filters.md"
+          "title": "Filters",
+          "path": "/guides/filters",
+          "editUrl": "/guides/filters.md"
         },
         {
           "title": "SSR & Next.js",
diff --git a/docs/src/pages/guides/query-filters.md b/docs/src/pages/guides/filters.md
similarity index 54%
rename from docs/src/pages/guides/query-filters.md
rename to docs/src/pages/guides/filters.md
index df20c61b5..132decaf9 100644
--- a/docs/src/pages/guides/query-filters.md
+++ b/docs/src/pages/guides/filters.md
@@ -1,9 +1,13 @@
 ---
-id: query-filters
-title: Query Filters
+id: filters
+title: Filters
 ---
 
-Some methods within React Query accept a `QueryFilters` object. A query filter is an object with certain conditions to match a query with:
+Some methods within React Query accept a `QueryFilters` or `MutationFilters` object.
+
+## `Query Filters`
+
+A query filter is an object with certain conditions to match a query with:
 
 ```js
 // Cancel all queries
@@ -39,3 +43,30 @@ A query filter object supports the following properties:
   - This predicate function will be called for every single query in the cache and be expected to return truthy for queries that are `found`.
 - `queryKey?: QueryKey`
   - Set this property to define a query key to match on.
+
+## `Mutation Filters`
+
+A mutation filter is an object with certain conditions to match a mutation with:
+
+```js
+// Get the number of all fetching mutations
+await queryClient.isMutating()
+
+// Filter mutations by mutationKey
+await queryClient.isMutating({ mutationKey: "post" })
+
+// Filter mutations using a predicate function
+await queryClient.isMutating({ predicate: (mutation) => mutation.options.variables?.id === 1 })
+```
+
+A mutation filter object supports the following properties:
+
+- `exact?: boolean`
+  - If you don't want to search mutations inclusively by mutation key, you can pass the `exact: true` option to return only the mutation with the exact mutation key you have passed.
+- `fetching?: boolean`
+  - When set to `true` it will match mutations that are currently fetching.
+  - When set to `false` it will match mutations that are not fetching.
+- `predicate?: (mutation: Mutation) => boolean`
+  - This predicate function will be called for every single mutation in the cache and be expected to return truthy for mutations that are `found`.
+- `mutationKey?: MutationKey`
+  - Set this property to define a mutation key to match on.
diff --git a/docs/src/pages/guides/query-invalidation.md b/docs/src/pages/guides/query-invalidation.md
index af2d31959..2f6b2b678 100644
--- a/docs/src/pages/guides/query-invalidation.md
+++ b/docs/src/pages/guides/query-invalidation.md
@@ -21,7 +21,7 @@ When a query is invalidated with `invalidateQueries`, two things happen:
 
 ## Query Matching with `invalidateQueries`
 
-When using APIs like `invalidateQueries` and `removeQueries` (and others that support partial query matching), you can match multiple queries by their prefix, or get really specific and match an exact query. For information on the types of filters you can use, please see [Query Filters](./query-filters).
+When using APIs like `invalidateQueries` and `removeQueries` (and others that support partial query matching), you can match multiple queries by their prefix, or get really specific and match an exact query. For information on the types of filters you can use, please see [Query Filters](./filters#query-filters).
 
 In this example, we can use the `todos` prefix to invalidate any queries that start with `todos` in their query key:
 
diff --git a/docs/src/pages/reference/QueryCache.md b/docs/src/pages/reference/QueryCache.md
index 3e26d55c2..f575eb332 100644
--- a/docs/src/pages/reference/QueryCache.md
+++ b/docs/src/pages/reference/QueryCache.md
@@ -45,7 +45,7 @@ const query = queryCache.find(queryKey)
 **Options**
 
 - `queryKey?: QueryKey`: [Query Keys](../guides/query-keys)
-- `filters?: QueryFilters`: [Query Filters](../guides/query-filters)
+- `filters?: QueryFilters`: [Query Filters](../guides/filters#query-filters)
 
 **Returns**
 
@@ -65,7 +65,7 @@ const queries = queryCache.findAll(queryKey)
 **Options**
 
 - `queryKey?: QueryKey`: [Query Keys](../guides/query-keys)
-- `filters?: QueryFilters`: [Query Filters](../guides/query-filters)
+- `filters?: QueryFilters`: [Query Filters](../guides/filters#query-filters)
 
 **Returns**
 
diff --git a/docs/src/pages/reference/QueryClient.md b/docs/src/pages/reference/QueryClient.md
index cd3d7e340..dd115ff16 100644
--- a/docs/src/pages/reference/QueryClient.md
+++ b/docs/src/pages/reference/QueryClient.md
@@ -36,6 +36,7 @@ Its available methods are:
 - [`removeQueries`](#queryclientremovequeries)
 - [`resetQueries`](#queryclientresetqueries)
 - [`isFetching`](#queryclientisfetching)
+- [`isMutating`](#queryclientismutating)
 - [`getDefaultOptions`](#queryclientsetdefaultoptions)
 - [`setDefaultOptions`](#queryclientgetdefaultoptions)
 - [`getQueryDefaults`](#queryclientgetquerydefaults)
@@ -166,7 +167,7 @@ const data = queryClient.getQueryData(queryKey)
 **Options**
 
 - `queryKey?: QueryKey`: [Query Keys](../guides/query-keys)
-- `filters?: QueryFilters`: [Query Filters](../guides/query-filters)
+- `filters?: QueryFilters`: [Query Filters](../guides/filters#query-filters)
 
 **Returns**
 
@@ -216,7 +217,7 @@ console.log(state.dataUpdatedAt)
 **Options**
 
 - `queryKey?: QueryKey`: [Query Keys](../guides/query-keys)
-- `filters?: QueryFilters`: [Query Filters](../guides/query-filters)
+- `filters?: QueryFilters`: [Query Filters](../guides/filters#query-filters)
 
 ## `queryClient.invalidateQueries`
 
@@ -236,7 +237,7 @@ await queryClient.invalidateQueries('posts', {
 **Options**
 
 - `queryKey?: QueryKey`: [Query Keys](../guides/query-keys)
-- `filters?: QueryFilters`: [Query Filters](../guides/query-filters)
+- `filters?: QueryFilters`: [Query Filters](../guides/filters#query-filters)
   - `refetchActive: Boolean`
     - Defaults to `true`
     - When set to `false`, queries that match the refetch predicate and are actively being rendered via `useQuery` and friends will NOT be refetched in the background, and only marked as invalid.
@@ -270,7 +271,7 @@ await queryClient.refetchQueries(['posts', 1], { active: true, exact: true })
 **Options**
 
 - `queryKey?: QueryKey`: [Query Keys](../guides/query-keys)
-- `filters?: QueryFilters`: [Query Filters](../guides/query-filters)
+- `filters?: QueryFilters`: [Query Filters](../guides/filters#query-filters)
 - `refetchOptions?: RefetchOptions`:
   - `throwOnError?: boolean`
     - When set to `true`, this method will throw if any of the query refetch tasks fail.
@@ -292,7 +293,7 @@ await queryClient.cancelQueries('posts', { exact: true })
 **Options**
 
 - `queryKey?: QueryKey`: [Query Keys](../guides/query-keys)
-- `filters?: QueryFilters`: [Query Filters](../guides/query-filters)
+- `filters?: QueryFilters`: [Query Filters](../guides/filters#query-filters)
 
 **Returns**
 
@@ -309,7 +310,7 @@ queryClient.removeQueries(queryKey, { exact: true })
 **Options**
 
 - `queryKey?: QueryKey`: [Query Keys](../guides/query-keys)
-- `filters?: QueryFilters`: [Query Filters](../guides/query-filters)
+- `filters?: QueryFilters`: [Query Filters](../guides/filters#query-filters)
 
 **Returns**
 
@@ -333,7 +334,7 @@ queryClient.resetQueries(queryKey, { exact: true })
 **Options**
 
 - `queryKey?: QueryKey`: [Query Keys](../guides/query-keys)
-- `filters?: QueryFilters`: [Query Filters](../guides/query-filters)
+- `filters?: QueryFilters`: [Query Filters](../guides/filters#query-filters)
 - `resetOptions?: ResetOptions`:
   - `throwOnError?: boolean`
     - When set to `true`, this method will throw if any of the query refetch tasks fail.
@@ -352,17 +353,36 @@ if (queryClient.isFetching()) {
 }
 ```
 
-React Query also exports a handy [`useIsFetching`](#useisfetching) hook that will let you subscribe to this state in your components without creating a manual subscription to the query cache.
+React Query also exports a handy [`useIsFetching`](./useIsFetching) hook that will let you subscribe to this state in your components without creating a manual subscription to the query cache.
 
 **Options**
 
 - `queryKey?: QueryKey`: [Query Keys](../guides/query-keys)
-- `filters?: QueryFilters`: [Query Filters](../guides/query-filters)
+- `filters?: QueryFilters`: [Query Filters](../guides/filters#query-filters)
 
 **Returns**
 
 This method returns the number of fetching queries.
 
+## `queryClient.isMutating`
+
+This `isMutating` method returns an `integer` representing how many mutations, if any, in the cache are currently fetching.
+
+```js
+if (queryClient.isMutating()) {
+  console.log('At least one mutation is fetching!')
+}
+```
+
+React Query also exports a handy [`useIsMutating`](./useIsMutating) hook that will let you subscribe to this state in your components without creating a manual subscription to the mutation cache.
+
+**Options**
+
+- `filters: MutationFilters`: [Query Filters](../guides/filters#mutation-filters)
+
+**Returns**
+
+This method returns the number of fetching mutations.
 ## `queryClient.getDefaultOptions`
 
 The `getDefaultOptions` method returns the default options which have been set when creating the client or with `setDefaultOptions`.
diff --git a/docs/src/pages/reference/useIsFetching.md b/docs/src/pages/reference/useIsFetching.md
index 20ce78e28..569c13344 100644
--- a/docs/src/pages/reference/useIsFetching.md
+++ b/docs/src/pages/reference/useIsFetching.md
@@ -16,7 +16,7 @@ const isFetchingPosts = useIsFetching(['posts'])
 **Options**
 
 - `queryKey?: QueryKey`: [Query Keys](../guides/query-keys)
-- `filters?: QueryFilters`: [Query Filters](../guides/query-filters)
+- `filters?: QueryFilters`: [Query Filters](../guides/filters#query-filters)
 
 **Returns**
 
diff --git a/docs/src/pages/reference/useIsMutating.md b/docs/src/pages/reference/useIsMutating.md
new file mode 100644
index 000000000..3fe0e8d9c
--- /dev/null
+++ b/docs/src/pages/reference/useIsMutating.md
@@ -0,0 +1,23 @@
+---
+id: useIsMutating
+title: useIsMutating
+---
+
+`useIsMutating` is an optional hook that returns the `number` of mutations that your application is fetching (useful for app-wide loading indicators).
+
+```js
+import { useIsMutating } from 'react-query'
+// How many mutations are fetching?
+const isMutating = useIsMutating()
+// How many mutations matching the posts prefix are fetching?
+const isMutatingPosts = useIsMutating(['posts'])
+```
+
+**Options**
+
+- `filters?: MutationFilters`: [Query Filters](../guides/filters#mutation-filters)
+
+**Returns**
+
+- `isMutating: number`
+  - Will be the `number` of the mutations that your application is currently fetching.
diff --git a/src/core/mutationCache.ts b/src/core/mutationCache.ts
index 1ed431750..d1649d9c3 100644
--- a/src/core/mutationCache.ts
+++ b/src/core/mutationCache.ts
@@ -2,7 +2,7 @@ import type { MutationOptions } from './types'
 import type { QueryClient } from './queryClient'
 import { notifyManager } from './notifyManager'
 import { Mutation, MutationState } from './mutation'
-import { noop } from './utils'
+import { matchMutation, MutationFilters, noop } from './utils'
 import { Subscribable } from './subscribable'
 
 // TYPES
@@ -76,6 +76,25 @@ export class MutationCache extends Subscribable<MutationCacheListener> {
     return this.mutations
   }
 
+  find<
+    TData = unknown,
+    TError = unknown,
+    TVariables = any,
+    TContext = unknown
+  >(
+    filters: MutationFilters
+  ): Mutation<TData, TError, TVariables, TContext> | undefined {
+    if (typeof filters.exact === 'undefined') {
+      filters.exact = true
+    }
+
+    return this.mutations.find(mutation => matchMutation(filters, mutation))
+  }
+
+  findAll(filters: MutationFilters): Mutation[] {
+    return this.mutations.filter(mutation => matchMutation(filters, mutation))
+  }
+
   notify(mutation?: Mutation<any, any, any, any>) {
     notifyManager.batch(() => {
       this.listeners.forEach(listener => {
diff --git a/src/core/queryClient.ts b/src/core/queryClient.ts
index a7230fbfb..85f47cf54 100644
--- a/src/core/queryClient.ts
+++ b/src/core/queryClient.ts
@@ -7,6 +7,7 @@ import {
   parseQueryArgs,
   partialMatchKey,
   hashQueryKeyByOptions,
+  MutationFilters,
 } from './utils'
 import type {
   DefaultOptions,
@@ -99,6 +100,10 @@ export class QueryClient {
     return this.queryCache.findAll(filters).length
   }
 
+  isMutating(filters?: MutationFilters): number {
+    return this.mutationCache.findAll({ ...filters, fetching: true }).length
+  }
+
   getQueryData<TData = unknown>(
     queryKey: QueryKey,
     filters?: QueryFilters
@@ -207,7 +212,7 @@ export class QueryClient {
     const refetchFilters: QueryFilters = {
       ...filters,
       active: filters.refetchActive ?? true,
-      inactive: filters.refetchInactive ?? false,
+      inactive: filters.refetchInactive,
     }
 
     return notifyManager.batch(() => {
diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts
index f88b4e791..061091ab8 100644
--- a/src/core/queryObserver.ts
+++ b/src/core/queryObserver.ts
@@ -57,6 +57,7 @@ export class QueryObserver<
     TQueryData
   >
   private previousQueryResult?: QueryObserverResult<TData, TError>
+  private previousSelectError: Error | null
   private staleTimeoutId?: number
   private refetchIntervalId?: number
   private trackedProps!: Array<keyof QueryObserverResult>
@@ -70,6 +71,7 @@ export class QueryObserver<
     this.client = client
     this.options = options
     this.trackedProps = []
+    this.previousSelectError = null
     this.bindMethods()
     this.setOptions(options)
   }
@@ -414,7 +416,8 @@ export class QueryObserver<
       if (
         prevResult &&
         state.data === prevResultState?.data &&
-        options.select === prevResultOptions?.select
+        options.select === prevResultOptions?.select &&
+        !this.previousSelectError
       ) {
         data = prevResult.data
       } else {
@@ -423,9 +426,11 @@ export class QueryObserver<
           if (options.structuralSharing !== false) {
             data = replaceEqualDeep(prevResult?.data, data)
           }
+          this.previousSelectError = null
         } catch (selectError) {
           getLogger().error(selectError)
           error = selectError
+          this.previousSelectError = selectError
           errorUpdatedAt = Date.now()
           status = 'error'
         }
diff --git a/src/core/utils.ts b/src/core/utils.ts
index 49cb0b7d6..5c643299d 100644
--- a/src/core/utils.ts
+++ b/src/core/utils.ts
@@ -1,3 +1,4 @@
+import type { Mutation } from './mutation'
 import type { Query } from './query'
 import type {
   MutationFunction,
@@ -41,6 +42,25 @@ export interface QueryFilters {
   fetching?: boolean
 }
 
+export interface MutationFilters {
+  /**
+   * Match mutation key exactly
+   */
+  exact?: boolean
+  /**
+   * Include mutations matching this predicate function
+   */
+  predicate?: (mutation: Mutation<any, any, any>) => boolean
+  /**
+   * Include mutations matching this mutation key
+   */
+  mutationKey?: MutationKey
+  /**
+   * Include or exclude fetching mutations
+   */
+  fetching?: boolean
+}
+
 export type DataUpdateFunction<TInput, TOutput> = (input: TInput) => TOutput
 
 export type Updater<TInput, TOutput> =
@@ -187,6 +207,40 @@ export function matchQuery(
   return true
 }
 
+export function matchMutation(
+  filters: MutationFilters,
+  mutation: Mutation<any, any>
+): boolean {
+  const { exact, fetching, predicate, mutationKey } = filters
+  if (isQueryKey(mutationKey)) {
+    if (!mutation.options.mutationKey) {
+      return false
+    }
+    if (exact) {
+      if (
+        hashQueryKey(mutation.options.mutationKey) !== hashQueryKey(mutationKey)
+      ) {
+        return false
+      }
+    } else if (!partialMatchKey(mutation.options.mutationKey, mutationKey)) {
+      return false
+    }
+  }
+
+  if (
+    typeof fetching === 'boolean' &&
+    (mutation.state.status === 'loading') !== fetching
+  ) {
+    return false
+  }
+
+  if (predicate && !predicate(mutation)) {
+    return false
+  }
+
+  return true
+}
+
 export function hashQueryKeyByOptions(
   queryKey: QueryKey,
   options?: QueryOptions<any, any>
@@ -222,10 +276,7 @@ export function stableValueHash(value: any): string {
 /**
  * Checks if key `b` partially matches with key `a`.
  */
-export function partialMatchKey(
-  a: QueryKey,
-  b: QueryKey
-): boolean {
+export function partialMatchKey(a: QueryKey, b: QueryKey): boolean {
   return partialDeepEqual(ensureArray(a), ensureArray(b))
 }
 
diff --git a/src/react/index.ts b/src/react/index.ts
index e9245345e..512d6d8e9 100644
--- a/src/react/index.ts
+++ b/src/react/index.ts
@@ -8,6 +8,7 @@ export {
   useQueryErrorResetBoundary,
 } from './QueryErrorResetBoundary'
 export { useIsFetching } from './useIsFetching'
+export { useIsMutating } from './useIsMutating'
 export { useMutation } from './useMutation'
 export { useQuery } from './useQuery'
 export { useQueries } from './useQueries'
diff --git a/src/react/useIsMutating.ts b/src/react/useIsMutating.ts
new file mode 100644
index 000000000..3f6987be2
--- /dev/null
+++ b/src/react/useIsMutating.ts
@@ -0,0 +1,42 @@
+import React from 'react'
+
+import { notifyManager } from '../core/notifyManager'
+import { MutationFilters } from '../core/utils'
+import { useQueryClient } from './QueryClientProvider'
+
+export function useIsMutating(filters?: MutationFilters): number {
+  const mountedRef = React.useRef(false)
+
+  const queryClient = useQueryClient()
+
+  const [isMutating, setIsMutating] = React.useState(
+    queryClient.isMutating(filters)
+  )
+
+  const filtersRef = React.useRef(filters)
+  filtersRef.current = filters
+  const isMutatingRef = React.useRef(isMutating)
+  isMutatingRef.current = isMutating
+
+  React.useEffect(() => {
+    mountedRef.current = true
+
+    const unsubscribe = queryClient.getMutationCache().subscribe(
+      notifyManager.batchCalls(() => {
+        if (mountedRef.current) {
+          const newIsMutating = queryClient.isMutating(filtersRef.current)
+          if (isMutatingRef.current !== newIsMutating) {
+            setIsMutating(newIsMutating)
+          }
+        }
+      })
+    )
+
+    return () => {
+      mountedRef.current = false
+      unsubscribe()
+    }
+  }, [queryClient])
+
+  return isMutating
+}


Test patch:
diff --git a/src/core/tests/mutationCache.test.tsx b/src/core/tests/mutationCache.test.tsx
index 9705b2228..c1d1135f2 100644
--- a/src/core/tests/mutationCache.test.tsx
+++ b/src/core/tests/mutationCache.test.tsx
@@ -25,4 +25,57 @@ describe('mutationCache', () => {
       expect(onError).toHaveBeenCalledWith('error', 'vars', 'context', mutation)
     })
   })
+
+  describe('find', () => {
+    test('should filter correctly', async () => {
+      const testCache = new MutationCache()
+      const testClient = new QueryClient({ mutationCache: testCache })
+      const key = ['mutation', 'vars']
+      await testClient.executeMutation({
+        mutationKey: key,
+        variables: 'vars',
+        mutationFn: () => Promise.resolve(),
+      })
+      const [mutation] = testCache.getAll()
+      expect(testCache.find({ mutationKey: key })).toEqual(mutation)
+      expect(testCache.find({ mutationKey: 'mutation', exact: false })).toEqual(
+        mutation
+      )
+      expect(testCache.find({ mutationKey: 'unknown' })).toEqual(undefined)
+      expect(
+        testCache.find({ predicate: m => m.options.variables === 'vars' })
+      ).toEqual(mutation)
+    })
+  })
+
+  describe('findAll', () => {
+    test('should filter correctly', async () => {
+      const testCache = new MutationCache()
+      const testClient = new QueryClient({ mutationCache: testCache })
+      await testClient.executeMutation({
+        mutationKey: ['a', 1],
+        variables: 1,
+        mutationFn: () => Promise.resolve(),
+      })
+      await testClient.executeMutation({
+        mutationKey: ['a', 2],
+        variables: 2,
+        mutationFn: () => Promise.resolve(),
+      })
+      await testClient.executeMutation({
+        mutationKey: 'b',
+        mutationFn: () => Promise.resolve(),
+      })
+
+      const [mutation1, mutation2] = testCache.getAll()
+      expect(
+        testCache.findAll({ mutationKey: 'a', exact: false })
+      ).toHaveLength(2)
+      expect(testCache.find({ mutationKey: ['a', 1] })).toEqual(mutation1)
+      expect(testCache.findAll({ mutationKey: 'unknown' })).toEqual([])
+      expect(
+        testCache.findAll({ predicate: m => m.options.variables === 2 })
+      ).toEqual([mutation2])
+    })
+  })
 })
diff --git a/src/core/tests/queryClient.test.tsx b/src/core/tests/queryClient.test.tsx
index f34d7a080..016324bf2 100644
--- a/src/core/tests/queryClient.test.tsx
+++ b/src/core/tests/queryClient.test.tsx
@@ -550,6 +550,22 @@ describe('queryClient', () => {
       expect(queryFn1).toHaveBeenCalledTimes(1)
       expect(queryFn2).toHaveBeenCalledTimes(1)
     })
+
+    test('should not refetch active queries when "refetchActive" is false', async () => {
+      const key1 = queryKey()
+      const queryFn1 = jest.fn()
+      await queryClient.fetchQuery(key1, queryFn1)
+      const observer = new QueryObserver(queryClient, {
+        queryKey: key1,
+        staleTime: Infinity,
+      })
+      const unsubscribe = observer.subscribe()
+      queryClient.invalidateQueries(key1, {
+        refetchActive: false,
+      })
+      unsubscribe()
+      expect(queryFn1).toHaveBeenCalledTimes(1)
+    })
   })
 
   describe('resetQueries', () => {
diff --git a/src/core/tests/queryObserver.test.tsx b/src/core/tests/queryObserver.test.tsx
index 35939d749..1467e50cb 100644
--- a/src/core/tests/queryObserver.test.tsx
+++ b/src/core/tests/queryObserver.test.tsx
@@ -240,6 +240,52 @@ describe('queryObserver', () => {
     expect(observerResult2.data).toMatchObject({ myCount: 1 })
   })
 
+  test('should always run the selector again if selector throws an error', async () => {
+    const key = queryKey()
+    const results: QueryObserverResult[] = []
+    const select = () => {
+      throw new Error('selector error')
+    }
+    const queryFn = () => ({ count: 1 })
+    const observer = new QueryObserver(queryClient, {
+      queryKey: key,
+      queryFn,
+      select,
+    })
+    const unsubscribe = observer.subscribe(result => {
+      results.push(result)
+    })
+    await sleep(1)
+    await observer.refetch()
+    unsubscribe()
+    expect(results.length).toBe(5)
+    expect(results[0]).toMatchObject({
+      status: 'loading',
+      isFetching: true,
+      data: undefined,
+    })
+    expect(results[1]).toMatchObject({
+      status: 'error',
+      isFetching: false,
+      data: undefined,
+    })
+    expect(results[2]).toMatchObject({
+      status: 'error',
+      isFetching: true,
+      data: undefined,
+    })
+    expect(results[3]).toMatchObject({
+      status: 'error',
+      isFetching: false,
+      data: undefined,
+    })
+    expect(results[4]).toMatchObject({
+      status: 'error',
+      isFetching: false,
+      data: undefined,
+    })
+  })
+
   test('should structurally share the selector', async () => {
     const key = queryKey()
     let count = 0
diff --git a/src/react/tests/useIsMutating.test.tsx b/src/react/tests/useIsMutating.test.tsx
new file mode 100644
index 000000000..2b1f5b644
--- /dev/null
+++ b/src/react/tests/useIsMutating.test.tsx
@@ -0,0 +1,108 @@
+import { waitFor } from '@testing-library/react'
+import React from 'react'
+import { QueryClient } from '../../core'
+import { useIsMutating } from '../useIsMutating'
+import { useMutation } from '../useMutation'
+import { renderWithClient, setActTimeout, sleep } from './utils'
+
+describe('useIsMutating', () => {
+  it('should return the number of fetching mutations', async () => {
+    const isMutatings: number[] = []
+    const queryClient = new QueryClient()
+
+    function IsMutating() {
+      const isMutating = useIsMutating()
+      isMutatings.push(isMutating)
+      return null
+    }
+
+    function Page() {
+      const { mutate: mutate1 } = useMutation('mutation1', async () => {
+        await sleep(150)
+        return 'data'
+      })
+      const { mutate: mutate2 } = useMutation('mutation2', async () => {
+        await sleep(50)
+        return 'data'
+      })
+
+      React.useEffect(() => {
+        mutate1()
+        setActTimeout(() => {
+          mutate2()
+        }, 50)
+      }, [mutate1, mutate2])
+
+      return <IsMutating />
+    }
+
+    renderWithClient(queryClient, <Page />)
+    await waitFor(() => expect(isMutatings).toEqual([0, 1, 1, 2, 2, 1, 0]))
+  })
+
+  it('should filter correctly by mutationKey', async () => {
+    const isMutatings: number[] = []
+    const queryClient = new QueryClient()
+
+    function IsMutating() {
+      const isMutating = useIsMutating({ mutationKey: 'mutation1' })
+      isMutatings.push(isMutating)
+      return null
+    }
+
+    function Page() {
+      const { mutate: mutate1 } = useMutation('mutation1', async () => {
+        await sleep(100)
+        return 'data'
+      })
+      const { mutate: mutate2 } = useMutation('mutation2', async () => {
+        await sleep(100)
+        return 'data'
+      })
+
+      React.useEffect(() => {
+        mutate1()
+        mutate2()
+      }, [mutate1, mutate2])
+
+      return <IsMutating />
+    }
+
+    renderWithClient(queryClient, <Page />)
+    await waitFor(() => expect(isMutatings).toEqual([0, 1, 1, 1, 0, 0]))
+  })
+
+  it('should filter correctly by predicate', async () => {
+    const isMutatings: number[] = []
+    const queryClient = new QueryClient()
+
+    function IsMutating() {
+      const isMutating = useIsMutating({
+        predicate: mutation => mutation.options.mutationKey === 'mutation1',
+      })
+      isMutatings.push(isMutating)
+      return null
+    }
+
+    function Page() {
+      const { mutate: mutate1 } = useMutation('mutation1', async () => {
+        await sleep(100)
+        return 'data'
+      })
+      const { mutate: mutate2 } = useMutation('mutation2', async () => {
+        await sleep(100)
+        return 'data'
+      })
+
+      React.useEffect(() => {
+        mutate1()
+        mutate2()
+      }, [mutate1, mutate2])
+
+      return <IsMutating />
+    }
+
+    renderWithClient(queryClient, <Page />)
+    await waitFor(() => expect(isMutatings).toEqual([0, 1, 1, 1, 0, 0]))
+  })
+})