Repository: tannerlinsley/react-query
Pull request #3066 (2021-12-07 11:10:56)

refactor(core): refactor internals from classes to a functions

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


codesandbox[bot]: This pull request is automatically built and testable in [CodeSandbox](https://codesandbox.io).

  To see build info of the built libraries, click [here](https://ci.codesandbox.io/status/tannerlinsley/react-query/pr/3066/builds/196438) or the icon next to each commit SHA.

Latest deployment of this branch, based on commit 06352779a346b9339260fc762eb194c55a2f317b:

|Sandbox| Source |
|--|--|
|[tannerlinsley/react-query: basic](https://codesandbox.io/s/tannerlinsley-react-query-basic-slx2q)| Configuration |
|[tannerlinsley/react-query: basic-typescript](https://codesandbox.io/s/tannerlinsley-react-query-basic-typescript-ej5v8)| Configuration |

codecov[bot]: # [Codecov](https://codecov.io/gh/tannerlinsley/react-query/pull/3066?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tanner+Linsley) Report
> Merging [#3066](https://codecov.io/gh/tannerlinsley/react-query/pull/3066?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tanner+Linsley) (0635277) into [alpha](https://codecov.io/gh/tannerlinsley/react-query/commit/b84681fdc7d37645cd149f40e98cb18fd8bc393c?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tanner+Linsley) (b84681f) will **decrease** coverage by `0.00%`.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/tannerlinsley/react-query/pull/3066/graphs/tree.svg?width=650&height=150&src=pr&token=jqEbswkEDQ&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tanner+Linsley)](https://codecov.io/gh/tannerlinsley/react-query/pull/3066?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tanner+Linsley)

```diff
@@            Coverage Diff             @@
##            alpha    #3066      +/-   ##
==========================================
- Coverage   96.80%   96.80%   -0.01%     
==========================================
  Files          47       47              
  Lines        2286     2284       -2     
  Branches      665      665              
==========================================
- Hits         2213     2211       -2     
  Misses         70       70              
  Partials        3        3              
```


| [Impacted Files](https://codecov.io/gh/tannerlinsley/react-query/pull/3066?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tanner+Linsley) | Coverage Δ | |
|---|---|---|
| [src/core/mutation.ts](https://codecov.io/gh/tannerlinsley/react-query/pull/3066/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tanner+Linsley#diff-c3JjL2NvcmUvbXV0YXRpb24udHM=) | `100.00% <100.00%> (ø)` | |
| [src/core/query.ts](https://codecov.io/gh/tannerlinsley/react-query/pull/3066/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tanner+Linsley#diff-c3JjL2NvcmUvcXVlcnkudHM=) | `100.00% <100.00%> (ø)` | |
| [src/core/retryer.ts](https://codecov.io/gh/tannerlinsley/react-query/pull/3066/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tanner+Linsley#diff-c3JjL2NvcmUvcmV0cnllci50cw==) | `100.00% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/tannerlinsley/react-query/pull/3066?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tanner+Linsley).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tanner+Linsley)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/tannerlinsley/react-query/pull/3066?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tanner+Linsley). Last update [b84681f...0635277](https://codecov.io/gh/tannerlinsley/react-query/pull/3066?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tanner+Linsley). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Tanner+Linsley).


Patch:
diff --git a/docs/src/pages/guides/does-this-replace-client-state.md b/docs/src/pages/guides/does-this-replace-client-state.md
index 1a907fdf8..f6f5c0c15 100644
--- a/docs/src/pages/guides/does-this-replace-client-state.md
+++ b/docs/src/pages/guides/does-this-replace-client-state.md
@@ -38,7 +38,7 @@ const globalState = {
 }
 ```
 
-This also means that with a few hook calls to `useQuery` and `useMutation`, we also get to remove any boilerplate code that use to manage our server state eg.
+This also means that with a few hook calls to `useQuery` and `useMutation`, we also get to remove any boilerplate code that was used to manage our server state eg.
 
 - Connectors
 - Action Creators
diff --git a/docs/src/pages/guides/window-focus-refetching.md b/docs/src/pages/guides/window-focus-refetching.md
index 0058831fe..4e7d0e635 100644
--- a/docs/src/pages/guides/window-focus-refetching.md
+++ b/docs/src/pages/guides/window-focus-refetching.md
@@ -68,10 +68,12 @@ import { AppState } from 'react-native'
 import { focusManager } from 'react-query'
 
 focusManager.setEventListener(handleFocus => {
-  AppState.addEventListener('change', handleFocus)
+  const subscription = AppState.addEventListener('change', state => {
+    handleFocus(state === 'active')
+  })
 
   return () => {
-   AppState.removeEventListener('change', handleFocus)
+    subscription.remove()
   }
 })
 ```
diff --git a/src/core/infiniteQueryObserver.ts b/src/core/infiniteQueryObserver.ts
index dc0a6a602..063b01ae8 100644
--- a/src/core/infiniteQueryObserver.ts
+++ b/src/core/infiniteQueryObserver.ts
@@ -6,7 +6,11 @@ import type {
   InfiniteQueryObserverResult,
 } from './types'
 import type { QueryClient } from './queryClient'
-import { ObserverFetchOptions, QueryObserver } from './queryObserver'
+import {
+  NotifyOptions,
+  ObserverFetchOptions,
+  QueryObserver,
+} from './queryObserver'
 import {
   hasNextPage,
   hasPreviousPage,
@@ -67,12 +71,16 @@ export class InfiniteQueryObserver<
       TError,
       TData,
       TQueryData
-    >
+    >,
+    notifyOptions?: NotifyOptions
   ): void {
-    super.setOptions({
-      ...options,
-      behavior: infiniteQueryBehavior(),
-    })
+    super.setOptions(
+      {
+        ...options,
+        behavior: infiniteQueryBehavior(),
+      },
+      notifyOptions
+    )
   }
 
   getOptimisticResult(
diff --git a/src/core/mutation.ts b/src/core/mutation.ts
index 093f4107e..04533c11e 100644
--- a/src/core/mutation.ts
+++ b/src/core/mutation.ts
@@ -4,7 +4,7 @@ import type { MutationObserver } from './mutationObserver'
 import { getLogger } from './logger'
 import { notifyManager } from './notifyManager'
 import { Removable } from './removable'
-import { canFetch, Retryer } from './retryer'
+import { canFetch, Retryer, createRetryer } from './retryer'
 import { noop } from './utils'
 
 // TYPES
@@ -90,7 +90,7 @@ export class Mutation<
 
   private observers: MutationObserver<TData, TError, TVariables, TContext>[]
   private mutationCache: MutationCache
-  private retryer?: Retryer<TData, TError>
+  private retryer?: Retryer<TData>
 
   constructor(config: MutationConfig<TData, TError, TVariables, TContext>) {
     super()
@@ -262,7 +262,7 @@ export class Mutation<
   }
 
   private executeMutation(): Promise<TData> {
-    this.retryer = new Retryer({
+    this.retryer = createRetryer({
       fn: () => {
         if (!this.options.mutationFn) {
           return Promise.reject('No mutationFn found')
diff --git a/src/core/notifyManager.ts b/src/core/notifyManager.ts
index b584f7bf8..dc1e7a0ca 100644
--- a/src/core/notifyManager.ts
+++ b/src/core/notifyManager.ts
@@ -8,43 +8,32 @@ type NotifyFunction = (callback: () => void) => void
 
 type BatchNotifyFunction = (callback: () => void) => void
 
-// CLASS
-
-export class NotifyManager {
-  private queue: NotifyCallback[]
-  private transactions: number
-  private notifyFn: NotifyFunction
-  private batchNotifyFn: BatchNotifyFunction
-
-  constructor() {
-    this.queue = []
-    this.transactions = 0
-
-    this.notifyFn = (callback: () => void) => {
-      callback()
-    }
-
-    this.batchNotifyFn = (callback: () => void) => {
-      callback()
-    }
+export function createNotifyManager() {
+  let queue: NotifyCallback[] = []
+  let transactions = 0
+  let notifyFn: NotifyFunction = callback => {
+    callback()
+  }
+  let batchNotifyFn: BatchNotifyFunction = (callback: () => void) => {
+    callback()
   }
 
-  batch<T>(callback: () => T): T {
-    this.transactions++
+  const batch = <T>(callback: () => T): T => {
+    transactions++
     const result = callback()
-    this.transactions--
-    if (!this.transactions) {
-      this.flush()
+    transactions--
+    if (!transactions) {
+      flush()
     }
     return result
   }
 
-  schedule(callback: NotifyCallback): void {
-    if (this.transactions) {
-      this.queue.push(callback)
+  const schedule = (callback: NotifyCallback): void => {
+    if (transactions) {
+      queue.push(callback)
     } else {
       scheduleMicrotask(() => {
-        this.notifyFn(callback)
+        notifyFn(callback)
       })
     }
   }
@@ -52,22 +41,22 @@ export class NotifyManager {
   /**
    * All calls to the wrapped function will be batched.
    */
-  batchCalls<T extends Function>(callback: T): T {
+  const batchCalls = <T extends Function>(callback: T): T => {
     return ((...args: any[]) => {
-      this.schedule(() => {
+      schedule(() => {
         callback(...args)
       })
     }) as any
   }
 
-  flush(): void {
-    const queue = this.queue
-    this.queue = []
-    if (queue.length) {
+  const flush = (): void => {
+    const originalQueue = queue
+    queue = []
+    if (originalQueue.length) {
       scheduleMicrotask(() => {
-        this.batchNotifyFn(() => {
-          queue.forEach(callback => {
-            this.notifyFn(callback)
+        batchNotifyFn(() => {
+          originalQueue.forEach(callback => {
+            notifyFn(callback)
           })
         })
       })
@@ -78,19 +67,26 @@ export class NotifyManager {
    * Use this method to set a custom notify function.
    * This can be used to for example wrap notifications with `React.act` while running tests.
    */
-  setNotifyFunction(fn: NotifyFunction) {
-    this.notifyFn = fn
+  const setNotifyFunction = (fn: NotifyFunction) => {
+    notifyFn = fn
   }
 
   /**
    * Use this method to set a custom function to batch notifications together into a single tick.
    * By default React Query will use the batch function provided by ReactDOM or React Native.
    */
-  setBatchNotifyFunction(fn: BatchNotifyFunction) {
-    this.batchNotifyFn = fn
+  const setBatchNotifyFunction = (fn: BatchNotifyFunction) => {
+    batchNotifyFn = fn
   }
+
+  return {
+    batch,
+    batchCalls,
+    schedule,
+    setNotifyFunction,
+    setBatchNotifyFunction,
+  } as const
 }
 
 // SINGLETON
-
-export const notifyManager = new NotifyManager()
+export const notifyManager = createNotifyManager()
diff --git a/src/core/query.ts b/src/core/query.ts
index 12e99622e..07e171ecc 100644
--- a/src/core/query.ts
+++ b/src/core/query.ts
@@ -21,7 +21,7 @@ import type { QueryCache } from './queryCache'
 import type { QueryObserver } from './queryObserver'
 import { notifyManager } from './notifyManager'
 import { getLogger } from './logger'
-import { Retryer, isCancelledError, canFetch } from './retryer'
+import { Retryer, isCancelledError, canFetch, createRetryer } from './retryer'
 import { Removable } from './removable'
 
 // TYPES
@@ -158,7 +158,7 @@ export class Query<
 
   private cache: QueryCache
   private promise?: Promise<TData>
-  private retryer?: Retryer<TData, TError>
+  private retryer?: Retryer<TData>
   private observers: QueryObserver<any, any, any, any, any>[]
   private defaultOptions?: QueryOptions<TQueryFnData, TError, TData, TQueryKey>
   private abortSignalConsumed: boolean
@@ -431,7 +431,7 @@ export class Query<
     }
 
     // Try to fetch the data
-    this.retryer = new Retryer({
+    this.retryer = createRetryer({
       fn: context.fetchFn as () => TData,
       abort: abortController?.abort?.bind(abortController),
       onSuccess: data => {
diff --git a/src/core/retryer.ts b/src/core/retryer.ts
index 537f1b28a..0b5f8bf85 100644
--- a/src/core/retryer.ts
+++ b/src/core/retryer.ts
@@ -18,6 +18,14 @@ interface RetryerConfig<TData = unknown, TError = unknown> {
   networkMode: NetworkMode | undefined
 }
 
+export interface Retryer<TData = unknown> {
+  promise: Promise<TData>
+  cancel: (cancelOptions?: CancelOptions) => void
+  continue: () => void
+  cancelRetry: () => void
+  continueRetry: () => void
+}
+
 export type RetryValue<TError> = boolean | number | ShouldRetryFunction<TError>
 
 type ShouldRetryFunction<TError> = (
@@ -55,160 +63,152 @@ export function isCancelledError(value: any): value is CancelledError {
   return value instanceof CancelledError
 }
 
-// CLASS
-
-export class Retryer<TData = unknown, TError = unknown> {
-  cancel: (options?: CancelOptions) => void
-  cancelRetry: () => void
-  continueRetry: () => void
-  continue: () => void
-  failureCount: number
-  isPaused: boolean
-  isResolved: boolean
-  promise: Promise<TData>
-
-  constructor(config: RetryerConfig<TData, TError>) {
-    let cancelRetry = false
-    let continueFn: ((value?: unknown) => void) | undefined
-    let promiseResolve: (data: TData) => void
-    let promiseReject: (error: TError) => void
-
-    this.cancel = (cancelOptions?: CancelOptions): void => {
-      if (!this.isResolved) {
-        reject(new CancelledError(cancelOptions))
-
-        config.abort?.()
-      }
-    }
-    this.cancelRetry = () => {
-      cancelRetry = true
+export function createRetryer<TData = unknown, TError = unknown>(
+  config: RetryerConfig<TData, TError>
+): Retryer<TData> {
+  let isRetryCancelled = false
+  let failureCount = 0
+  let isResolved = false
+  const promise = new Promise<TData>((outerResolve, outerReject) => {
+    promiseResolve = outerResolve
+    promiseReject = outerReject
+  })
+  let continueFn: ((value?: unknown) => void) | undefined
+  let promiseResolve: (data: TData) => void
+  let promiseReject: (error: TError) => void
+
+  const cancel = (cancelOptions?: CancelOptions): void => {
+    if (!isResolved) {
+      reject(new CancelledError(cancelOptions))
+
+      config.abort?.()
     }
+  }
+  const cancelRetry = () => {
+    isRetryCancelled = true
+  }
 
-    this.continueRetry = () => {
-      cancelRetry = false
-    }
+  const continueRetry = () => {
+    isRetryCancelled = false
+  }
 
-    const shouldPause = () =>
-      !focusManager.isFocused() ||
-      (config.networkMode !== 'always' && !onlineManager.isOnline())
+  const shouldPause = () =>
+    !focusManager.isFocused() ||
+    (config.networkMode !== 'always' && !onlineManager.isOnline())
 
-    this.continue = () => {
+  const resolve = (value: any) => {
+    if (!isResolved) {
+      isResolved = true
+      config.onSuccess?.(value)
       continueFn?.()
+      promiseResolve(value)
     }
-    this.failureCount = 0
-    this.isPaused = false
-    this.isResolved = false
-    this.promise = new Promise<TData>((outerResolve, outerReject) => {
-      promiseResolve = outerResolve
-      promiseReject = outerReject
-    })
+  }
 
-    const resolve = (value: any) => {
-      if (!this.isResolved) {
-        this.isResolved = true
-        config.onSuccess?.(value)
-        continueFn?.()
-        promiseResolve(value)
-      }
+  const reject = (value: any) => {
+    if (!isResolved) {
+      isResolved = true
+      config.onError?.(value)
+      continueFn?.()
+      promiseReject(value)
     }
+  }
 
-    const reject = (value: any) => {
-      if (!this.isResolved) {
-        this.isResolved = true
-        config.onError?.(value)
-        continueFn?.()
-        promiseReject(value)
+  const pause = () => {
+    return new Promise(continueResolve => {
+      continueFn = value => {
+        if (isResolved || !shouldPause()) {
+          return continueResolve(value)
+        }
+      }
+      config.onPause?.()
+    }).then(() => {
+      continueFn = undefined
+      if (!isResolved) {
+        config.onContinue?.()
       }
+    })
+  }
+
+  // Create loop function
+  const run = () => {
+    // Do nothing if already resolved
+    if (isResolved) {
+      return
     }
 
-    const pause = () => {
-      return new Promise(continueResolve => {
-        continueFn = value => {
-          if (this.isResolved || !shouldPause()) {
-            return continueResolve(value)
-          }
-        }
-        this.isPaused = true
-        config.onPause?.()
-      }).then(() => {
-        continueFn = undefined
-        this.isPaused = false
-        if (!this.isResolved) {
-          config.onContinue?.()
-        }
-      })
+    let promiseOrValue: any
+
+    // Execute query
+    try {
+      promiseOrValue = config.fn()
+    } catch (error) {
+      promiseOrValue = Promise.reject(error)
     }
 
-    // Create loop function
-    const run = () => {
-      // Do nothing if already resolved
-      if (this.isResolved) {
-        return
-      }
+    Promise.resolve(promiseOrValue)
+      .then(resolve)
+      .catch(error => {
+        // Stop if the fetch is already resolved
+        if (isResolved) {
+          return
+        }
 
-      let promiseOrValue: any
+        // Do we need to retry the request?
+        const retry = config.retry ?? 3
+        const retryDelay = config.retryDelay ?? defaultRetryDelay
+        const delay =
+          typeof retryDelay === 'function'
+            ? retryDelay(failureCount, error)
+            : retryDelay
+        const shouldRetry =
+          retry === true ||
+          (typeof retry === 'number' && failureCount < retry) ||
+          (typeof retry === 'function' && retry(failureCount, error))
+
+        if (isRetryCancelled || !shouldRetry) {
+          // We are done if the query does not need to be retried
+          reject(error)
+          return
+        }
 
-      // Execute query
-      try {
-        promiseOrValue = config.fn()
-      } catch (error) {
-        promiseOrValue = Promise.reject(error)
-      }
+        failureCount++
+
+        // Notify on fail
+        config.onFail?.(failureCount, error)
+
+        // Delay
+        sleep(delay)
+          // Pause if the document is not visible or when the device is offline
+          .then(() => {
+            if (shouldPause()) {
+              return pause()
+            }
+          })
+          .then(() => {
+            if (isRetryCancelled) {
+              reject(error)
+            } else {
+              run()
+            }
+          })
+      })
+  }
 
-      Promise.resolve(promiseOrValue)
-        .then(resolve)
-        .catch(error => {
-          // Stop if the fetch is already resolved
-          if (this.isResolved) {
-            return
-          }
-
-          // Do we need to retry the request?
-          const retry = config.retry ?? 3
-          const retryDelay = config.retryDelay ?? defaultRetryDelay
-          const delay =
-            typeof retryDelay === 'function'
-              ? retryDelay(this.failureCount, error)
-              : retryDelay
-          const shouldRetry =
-            retry === true ||
-            (typeof retry === 'number' && this.failureCount < retry) ||
-            (typeof retry === 'function' && retry(this.failureCount, error))
-
-          if (cancelRetry || !shouldRetry) {
-            // We are done if the query does not need to be retried
-            reject(error)
-            return
-          }
-
-          this.failureCount++
-
-          // Notify on fail
-          config.onFail?.(this.failureCount, error)
-
-          // Delay
-          sleep(delay)
-            // Pause if the document is not visible or when the device is offline
-            .then(() => {
-              if (shouldPause()) {
-                return pause()
-              }
-            })
-            .then(() => {
-              if (cancelRetry) {
-                reject(error)
-              } else {
-                run()
-              }
-            })
-        })
-    }
+  // Start loop
+  if (canFetch(config.networkMode)) {
+    run()
+  } else {
+    pause().then(run)
+  }
 
-    // Start loop
-    if (canFetch(config.networkMode)) {
-      run()
-    } else {
-      pause().then(run)
-    }
+  return {
+    promise,
+    cancel,
+    continue: () => {
+      continueFn?.()
+    },
+    cancelRetry,
+    continueRetry,
   }
 }


Test patch:
diff --git a/src/core/tests/notifyManager.test.tsx b/src/core/tests/notifyManager.test.tsx
index 53816aa76..fc24c2c8c 100644
--- a/src/core/tests/notifyManager.test.tsx
+++ b/src/core/tests/notifyManager.test.tsx
@@ -1,9 +1,9 @@
-import { NotifyManager } from '../notifyManager'
+import { createNotifyManager } from '../notifyManager'
 import { sleep } from '../../devtools/tests/utils'
 
 describe('notifyManager', () => {
   it('should use default notifyFn', async () => {
-    const notifyManagerTest = new NotifyManager()
+    const notifyManagerTest = createNotifyManager()
     const callbackSpy = jest.fn()
     notifyManagerTest.schedule(callbackSpy)
     await sleep(1)
@@ -11,7 +11,7 @@ describe('notifyManager', () => {
   })
 
   it('should use default batchNotifyFn', async () => {
-    const notifyManagerTest = new NotifyManager()
+    const notifyManagerTest = createNotifyManager()
     const callbackScheduleSpy = jest
       .fn()
       .mockImplementation(async () => await sleep(20))
diff --git a/src/reactjs/tests/suspense.test.tsx b/src/reactjs/tests/suspense.test.tsx
index 45e35d70d..fefff96e4 100644
--- a/src/reactjs/tests/suspense.test.tsx
+++ b/src/reactjs/tests/suspense.test.tsx
@@ -103,8 +103,8 @@ describe("useQuery's in Suspense mode", () => {
     fireEvent.click(rendered.getByText('next'))
     await sleep(10)
 
-    expect(states.length).toBe(3)
-    expect(states[2]).toMatchObject({
+    expect(states.length).toBe(2)
+    expect(states[1]).toMatchObject({
       data: { pages: [2], pageParams: [undefined] },
       status: 'success',
     })
diff --git a/src/reactjs/tests/useInfiniteQuery.test.tsx b/src/reactjs/tests/useInfiniteQuery.test.tsx
index 1149602b0..1b15cdb5e 100644
--- a/src/reactjs/tests/useInfiniteQuery.test.tsx
+++ b/src/reactjs/tests/useInfiniteQuery.test.tsx
@@ -15,6 +15,7 @@ import {
   QueryClient,
   QueryCache,
   QueryFunctionContext,
+  InfiniteData,
 } from '../..'
 
 interface Result {
@@ -298,6 +299,41 @@ describe('useInfiniteQuery', () => {
     })
   })
 
+  it('should be able to select a new result and not cause infinite renders', async () => {
+    const key = queryKey()
+    const states: UseInfiniteQueryResult<{ count: number; id: number }>[] = []
+    let selectCalled = 0
+
+    function Page() {
+      const state = useInfiniteQuery(key, () => ({ count: 1 }), {
+        select: React.useCallback((data: InfiniteData<{ count: number }>) => {
+          selectCalled++
+          return {
+            pages: data.pages.map(x => ({ ...x, id: Math.random() })),
+            pageParams: data.pageParams,
+          }
+        }, []),
+      })
+      states.push(state)
+      return null
+    }
+
+    renderWithClient(queryClient, <Page />)
+
+    await sleep(20)
+
+    expect(states.length).toBe(2)
+    expect(selectCalled).toBe(1)
+    expect(states[0]).toMatchObject({
+      data: undefined,
+      isSuccess: false,
+    })
+    expect(states[1]).toMatchObject({
+      data: { pages: [{ count: 1 }] },
+      isSuccess: true,
+    })
+  })
+
   it('should be able to reverse the data', async () => {
     const key = queryKey()
     const states: UseInfiniteQueryResult<number>[] = []