Repository: tannerlinsley/react-query
Pull request #989 (2020-09-09 17:14:03)

feat: implement batch rendering
Fixes https://github.com/tannerlinsley/react-query/discussions/983

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


tannerlinsley: Looks nice!
tannerlinsley: Rebase and this is good to go!
boschni: Rebased!

Patch:
diff --git a/docs/src/pages/docs/comparison.md b/docs/src/pages/docs/comparison.md
index e3c6b6928..286210578 100644
--- a/docs/src/pages/docs/comparison.md
+++ b/docs/src/pages/docs/comparison.md
@@ -33,16 +33,17 @@ Feature/Capability Key:
 | Scroll Recovery                              | ✅                                     | ✅                         | ✅                                  |
 | Cache Manipulation                           | ✅                                     | ✅                         | ✅                                  |
 | Outdated Query Dismissal                     | ✅                                     | ✅                         | ✅                                  |
+| Render Optimization<sup>2</sup>              | ✅                                     | 🛑                         | 🛑                                  |
 | Auto Garbage Collection                      | ✅                                     | 🛑                         | 🛑                                  |
 | Mutation Hooks                               | ✅                                     | 🟡                         | ✅                                  |
 | Prefetching APIs                             | ✅                                     | 🔶                         | ✅                                  |
 | Query Cancellation                           | ✅                                     | 🛑                         | 🛑                                  |
-| Partial Query Matching<sup>2</sup>           | ✅                                     | 🛑                         | 🛑                                  |
+| Partial Query Matching<sup>3</sup>           | ✅                                     | 🛑                         | 🛑                                  |
 | Stale While Revalidate                       | ✅                                     | ✅                         | 🛑                                  |
 | Stale Time Configuration                     | ✅                                     | 🛑                         | 🛑                                  |
 | Window Focus Refetching                      | ✅                                     | ✅                         | 🛑                                  |
 | Network Status Refetching                    | ✅                                     | ✅                         | ✅                                  |
-| Automatic Refetch after Mutation<sup>3</sup> | 🔶                                     | 🔶                         | ✅                                  |
+| Automatic Refetch after Mutation<sup>4</sup> | 🔶                                     | 🔶                         | ✅                                  |
 | Cache Dehydration/Rehydration                | ✅                                     | 🛑                         | ✅                                  |
 | React Suspense (Experimental)                | ✅                                     | ✅                         | 🛑                                  |
 
@@ -50,9 +51,11 @@ Feature/Capability Key:
 
 > **<sup>1</sup> Lagged / "Lazy" Queries** - React Query provides a way to continue to see an existing query's data while the next query loads (similar to the same UX that suspense will soon provide natively). This is extremely important when writing pagination UIs or infinite loading UIs where you do not want to show a hard loading state whenever a new query is requested. Other libraries do not have this capability and render a hard loading state for the new query (unless it has been prefetched), while the new query loads.
 
-> **<sup>2</sup> Partial query matching** - Because React Query uses deterministic query key serialization, this allows you to manipulate variable groups of queries without having to know each individual query-key that you want to match, eg. you can refetch every query that starts with `todos` in its key, regardless of variables, or you can target specific queries with (or without) variables or nested properties, and even use a filter function to only match queries that pass your specific conditions.
+> **<sup>2</sup> Render Optimization** - React Query has excellent rendering performance. It will only re-render your components when a query is updated. For example because it has new data, or to indicate it is fetching. React Query also batches updates together to make sure your application only re-renders once when multiple components are using the same query. If you are only interested in the `data` or `error` properties, you can reduce the number of renders even more by setting `notifyOnStatusChange` to `false`.
 
-> **<sup>3</sup> Automatic Refetch after Mutation** - For truly automatic refetching to happen after a mutation occurs, a schema is necessary (like the one graphQL provides) along with heuristics that help the library know how to identify individual entities and entities types in that schema.
+> **<sup>3</sup> Partial query matching** - Because React Query uses deterministic query key serialization, this allows you to manipulate variable groups of queries without having to know each individual query-key that you want to match, eg. you can refetch every query that starts with `todos` in its key, regardless of variables, or you can target specific queries with (or without) variables or nested properties, and even use a filter function to only match queries that pass your specific conditions.
+
+> **<sup>4</sup> Automatic Refetch after Mutation** - For truly automatic refetching to happen after a mutation occurs, a schema is necessary (like the one graphQL provides) along with heuristics that help the library know how to identify individual entities and entities types in that schema.
 
 [swr]: https://github.com/vercel/swr
 [apollo]: https://github.com/apollographql/apollo-client
diff --git a/rollup.config.js b/rollup.config.js
index 9b1e4f1ca..3b5badf04 100644
--- a/rollup.config.js
+++ b/rollup.config.js
@@ -7,11 +7,12 @@ import commonJS from 'rollup-plugin-commonjs'
 import visualizer from 'rollup-plugin-visualizer'
 import replace from '@rollup/plugin-replace'
 
-const external = ['react']
+const external = ['react', 'react-dom']
 const hydrationExternal = [...external, 'react-query']
 
 const globals = {
   react: 'React',
+  'react-dom': 'ReactDOM',
 }
 const hydrationGlobals = {
   ...globals,
diff --git a/src/core/index.ts b/src/core/index.ts
index 4a6d20e43..883f4185a 100644
--- a/src/core/index.ts
+++ b/src/core/index.ts
@@ -7,7 +7,13 @@ export {
 } from './queryCache'
 export { setFocusHandler } from './setFocusHandler'
 export { setOnlineHandler } from './setOnlineHandler'
-export { CancelledError, isCancelledError, isError, setConsole } from './utils'
+export {
+  CancelledError,
+  isCancelledError,
+  isError,
+  setConsole,
+  setBatchedUpdates,
+} from './utils'
 
 // Types
 export * from './types'
diff --git a/src/core/notifyManager.ts b/src/core/notifyManager.ts
new file mode 100644
index 000000000..ae7083675
--- /dev/null
+++ b/src/core/notifyManager.ts
@@ -0,0 +1,56 @@
+import { getBatchedUpdates, scheduleMicrotask } from './utils'
+
+// TYPES
+
+type NotifyCallback = () => void
+
+// CLASS
+
+export class NotifyManager {
+  private queue: NotifyCallback[]
+  private transactions: number
+
+  constructor() {
+    this.queue = []
+    this.transactions = 0
+  }
+
+  batch<T>(callback: () => T): T {
+    this.transactions++
+    const result = callback()
+    this.transactions--
+    if (!this.transactions) {
+      this.flush()
+    }
+    return result
+  }
+
+  schedule(notify: NotifyCallback): void {
+    if (this.transactions) {
+      this.queue.push(notify)
+    } else {
+      scheduleMicrotask(() => {
+        notify()
+      })
+    }
+  }
+
+  flush(): void {
+    const queue = this.queue
+    this.queue = []
+    if (queue.length) {
+      scheduleMicrotask(() => {
+        const batchedUpdates = getBatchedUpdates()
+        batchedUpdates(() => {
+          queue.forEach(notify => {
+            notify()
+          })
+        })
+      })
+    }
+  }
+}
+
+// SINGLETON
+
+export const notifyManager = new NotifyManager()
diff --git a/src/core/query.ts b/src/core/query.ts
index 87191f446..b61bae178 100644
--- a/src/core/query.ts
+++ b/src/core/query.ts
@@ -23,6 +23,7 @@ import {
 } from './types'
 import type { QueryCache } from './queryCache'
 import { QueryObserver, UpdateListener } from './queryObserver'
+import { notifyManager } from './notifyManager'
 
 // TYPES
 
@@ -134,11 +135,13 @@ export class Query<TResult, TError> {
   private dispatch(action: Action<TResult, TError>): void {
     this.state = queryReducer(this.state, action)
 
-    this.observers.forEach(observer => {
-      observer.onQueryUpdate(action)
-    })
+    notifyManager.batch(() => {
+      this.observers.forEach(observer => {
+        observer.onQueryUpdate(action)
+      })
 
-    this.queryCache.notifyGlobalListeners(this)
+      this.queryCache.notifyGlobalListeners(this)
+    })
   }
 
   private scheduleGc(): void {
diff --git a/src/core/queryCache.ts b/src/core/queryCache.ts
index 8853efc91..cf98dc415 100644
--- a/src/core/queryCache.ts
+++ b/src/core/queryCache.ts
@@ -18,6 +18,7 @@ import {
   TypedQueryFunctionArgs,
   ResolvedQueryConfig,
 } from './types'
+import { notifyManager } from './notifyManager'
 
 // TYPES
 
@@ -94,8 +95,12 @@ export class QueryCache {
       0
     )
 
-    this.globalListeners.forEach(listener => {
-      listener(this, query)
+    notifyManager.batch(() => {
+      this.globalListeners.forEach(listener => {
+        notifyManager.schedule(() => {
+          listener(this, query)
+        })
+      })
     })
   }
 
@@ -209,8 +214,10 @@ export class QueryCache {
   ): Promise<Query<unknown, unknown>[]> {
     const queries = this.getQueries(predicate, options)
 
-    queries.forEach(query => {
-      query.invalidate()
+    notifyManager.batch(() => {
+      queries.forEach(query => {
+        query.invalidate()
+      })
     })
 
     const { refetchActive = true, refetchInactive = false } = options || {}
@@ -245,14 +252,16 @@ export class QueryCache {
   ): Promise<Query<unknown, unknown>[]> {
     const promises: Promise<Query<unknown, unknown>>[] = []
 
-    this.getQueries(predicate, options).forEach(query => {
-      let promise = query.fetch().then(() => query)
+    notifyManager.batch(() => {
+      this.getQueries(predicate, options).forEach(query => {
+        let promise = query.fetch().then(() => query)
 
-      if (!options?.throwOnError) {
-        promise = promise.catch(() => query)
-      }
+        if (!options?.throwOnError) {
+          promise = promise.catch(() => query)
+        }
 
-      promises.push(promise)
+        promises.push(promise)
+      })
     })
 
     return Promise.all(promises)
@@ -398,9 +407,11 @@ export function makeQueryCache(config?: QueryCacheConfig) {
 
 export function onVisibilityOrOnlineChange(type: 'focus' | 'online') {
   if (isDocumentVisible() && isOnline()) {
-    queryCaches.forEach(queryCache => {
-      queryCache.getQueries().forEach(query => {
-        query.onInteraction(type)
+    notifyManager.batch(() => {
+      queryCaches.forEach(queryCache => {
+        queryCache.getQueries().forEach(query => {
+          query.onInteraction(type)
+        })
       })
     })
   }
diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts
index e88b650b7..6241f5800 100644
--- a/src/core/queryObserver.ts
+++ b/src/core/queryObserver.ts
@@ -5,6 +5,7 @@ import {
   isValidTimeout,
   noop,
 } from './utils'
+import { notifyManager } from './notifyManager'
 import type { QueryResult, ResolvedQueryConfig } from './types'
 import type { Query, Action, FetchMoreOptions, RefetchOptions } from './query'
 
@@ -149,8 +150,15 @@ export class QueryObserver<TResult, TError> {
     }
   }
 
-  private notify(): void {
-    this.listener?.(this.currentResult)
+  private notify(global?: boolean): void {
+    notifyManager.batch(() => {
+      notifyManager.schedule(() => {
+        this.listener?.(this.currentResult)
+      })
+      if (global) {
+        this.config.queryCache.notifyGlobalListeners(this.currentQuery)
+      }
+    })
   }
 
   private updateStaleTimeout(): void {
@@ -172,8 +180,7 @@ export class QueryObserver<TResult, TError> {
       if (!this.isStale) {
         this.isStale = true
         this.updateResult()
-        this.notify()
-        this.config.queryCache.notifyGlobalListeners(this.currentQuery)
+        this.notify(true)
       }
     }, timeout)
   }
@@ -221,20 +228,19 @@ export class QueryObserver<TResult, TError> {
   }
 
   private updateResult(): void {
-    const { currentQuery, previousQueryResult, config } = this
-    const { state } = currentQuery
+    const { state } = this.currentQuery
     let { data, status, updatedAt } = state
     let isPreviousData = false
 
     // Keep previous data if needed
     if (
-      config.keepPreviousData &&
+      this.config.keepPreviousData &&
       state.isInitialData &&
-      previousQueryResult?.isSuccess
+      this.previousQueryResult?.isSuccess
     ) {
-      data = previousQueryResult.data
-      updatedAt = previousQueryResult.updatedAt
-      status = previousQueryResult.status
+      data = this.previousQueryResult.data
+      updatedAt = this.previousQueryResult.updatedAt
+      status = this.previousQueryResult.status
       isPreviousData = true
     }
 
diff --git a/src/core/utils.ts b/src/core/utils.ts
index 7875894a1..ddbce0d6c 100644
--- a/src/core/utils.ts
+++ b/src/core/utils.ts
@@ -250,3 +250,34 @@ export function createSetHandler(fn: () => void) {
     removePreviousHandler = callback(fn)
   }
 }
+
+/**
+ * Schedules a microtask.
+ * This can be useful to schedule state updates after rendering.
+ */
+export function scheduleMicrotask(callback: () => void): void {
+  Promise.resolve()
+    .then(callback)
+    .catch(error =>
+      setTimeout(() => {
+        throw error
+      })
+    )
+}
+
+type BatchUpdateFunction = (callback: () => void) => void
+
+// Default to a dummy "batch" implementation that just runs the callback
+let batchedUpdates: BatchUpdateFunction = (callback: () => void) => {
+  callback()
+}
+
+// Allow injecting another batching function later
+export function setBatchedUpdates(fn: BatchUpdateFunction) {
+  batchedUpdates = fn
+}
+
+// Supply a getter just to skip dealing with ESM bindings
+export function getBatchedUpdates(): BatchUpdateFunction {
+  return batchedUpdates
+}
diff --git a/src/index.ts b/src/index.ts
index 24c75db98..adfaa66a1 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,2 +1,6 @@
+import { setBatchedUpdates } from './core/index'
+import { unstable_batchedUpdates } from './react/reactBatchedUpdates'
+setBatchedUpdates(unstable_batchedUpdates)
+
 export * from './core/index'
 export * from './react/index'
diff --git a/src/react/reactBatchedUpdates.native.ts b/src/react/reactBatchedUpdates.native.ts
new file mode 100644
index 000000000..ae1adf81d
--- /dev/null
+++ b/src/react/reactBatchedUpdates.native.ts
@@ -0,0 +1,3 @@
+// @ts-ignore
+import { unstable_batchedUpdates } from 'react-native'
+export { unstable_batchedUpdates }
diff --git a/src/react/reactBatchedUpdates.ts b/src/react/reactBatchedUpdates.ts
new file mode 100644
index 000000000..baa3d01ee
--- /dev/null
+++ b/src/react/reactBatchedUpdates.ts
@@ -0,0 +1,2 @@
+import ReactDOM from 'react-dom'
+export const unstable_batchedUpdates = ReactDOM.unstable_batchedUpdates
diff --git a/src/react/useBaseQuery.ts b/src/react/useBaseQuery.ts
index b81182481..7aebcc9e0 100644
--- a/src/react/useBaseQuery.ts
+++ b/src/react/useBaseQuery.ts
@@ -1,6 +1,6 @@
 import React from 'react'
 
-import { useRerenderer } from './utils'
+import { useIsMounted } from './utils'
 import { getResolvedQueryConfig } from '../core/config'
 import { QueryObserver } from '../core/queryObserver'
 import { QueryResultBase, QueryKey, QueryConfig } from '../core/types'
@@ -12,8 +12,9 @@ export function useBaseQuery<TResult, TError>(
   queryKey: QueryKey,
   config?: QueryConfig<TResult, TError>
 ): QueryResultBase<TResult, TError> {
+  const [, rerender] = React.useReducer(c => c + 1, 0)
+  const isMounted = useIsMounted()
   const cache = useQueryCache()
-  const rerender = useRerenderer()
   const contextConfig = useContextConfig()
   const errorResetBoundary = useErrorResetBoundary()
 
@@ -35,9 +36,11 @@ export function useBaseQuery<TResult, TError>(
   React.useEffect(
     () =>
       observer.subscribe(() => {
-        rerender()
+        if (isMounted()) {
+          rerender()
+        }
       }),
-    [observer, rerender]
+    [isMounted, observer, rerender]
   )
 
   // Update config
diff --git a/src/react/useIsFetching.ts b/src/react/useIsFetching.ts
index e8acfa335..2f3658f0b 100644
--- a/src/react/useIsFetching.ts
+++ b/src/react/useIsFetching.ts
@@ -1,19 +1,21 @@
 import React from 'react'
 
 import { useQueryCache } from './ReactQueryCacheProvider'
-import { useSafeState } from './utils'
+import { useIsMounted } from './utils'
 
 export function useIsFetching(): number {
+  const isMounted = useIsMounted()
   const queryCache = useQueryCache()
-
-  const [isFetching, setIsFetching] = useSafeState(queryCache.isFetching)
+  const [isFetching, setIsFetching] = React.useState(queryCache.isFetching)
 
   React.useEffect(
     () =>
       queryCache.subscribe(() => {
-        setIsFetching(queryCache.isFetching)
+        if (isMounted()) {
+          setIsFetching(queryCache.isFetching)
+        }
       }),
-    [queryCache, setIsFetching]
+    [queryCache, setIsFetching, isMounted]
   )
 
   return isFetching
diff --git a/src/react/utils.ts b/src/react/utils.ts
index 034b03f4c..ff279c30d 100644
--- a/src/react/utils.ts
+++ b/src/react/utils.ts
@@ -2,7 +2,7 @@ import React from 'react'
 
 import { isServer } from '../core/utils'
 
-function useIsMounted(): () => boolean {
+export function useIsMounted(): () => boolean {
   const mountedRef = React.useRef(false)
   const isMounted = React.useCallback(() => mountedRef.current, [])
 
@@ -27,47 +27,3 @@ export function useMountedCallback<T extends Function>(callback: T): T {
     [callback, isMounted]
   ) as any) as T
 }
-
-/**
- * This hook is a safe useState version which schedules state updates in microtasks
- * to prevent updating a component state while React is rendering different components
- * or when the component is not mounted anymore.
- */
-export function useSafeState<S>(
-  initialState: S | (() => S)
-): [S, React.Dispatch<React.SetStateAction<S>>] {
-  const isMounted = useIsMounted()
-  const [state, setState] = React.useState(initialState)
-
-  const safeSetState = React.useCallback(
-    (value: React.SetStateAction<S>) => {
-      scheduleMicrotask(() => {
-        if (isMounted()) {
-          setState(value)
-        }
-      })
-    },
-    [isMounted]
-  )
-
-  return [state, safeSetState]
-}
-
-export function useRerenderer() {
-  const [, setState] = useSafeState({})
-  return React.useCallback(() => setState({}), [setState])
-}
-
-/**
- * Schedules a microtask.
- * This can be useful to schedule state updates after rendering.
- */
-function scheduleMicrotask(callback: () => void): void {
-  Promise.resolve()
-    .then(callback)
-    .catch(error =>
-      setTimeout(() => {
-        throw error
-      })
-    )
-}


Test patch:
diff --git a/src/core/tests/queryCache.test.tsx b/src/core/tests/queryCache.test.tsx
index e0837cfc8..3b83c51ee 100644
--- a/src/core/tests/queryCache.test.tsx
+++ b/src/core/tests/queryCache.test.tsx
@@ -5,7 +5,7 @@ import {
   mockConsoleError,
   mockNavigatorOnLine,
 } from '../../react/tests/utils'
-import { queryCache as defaultQueryCache, QueryCache } from '..'
+import { QueryCache, queryCache as defaultQueryCache } from '../..'
 import { isCancelledError, isError } from '../utils'
 
 describe('queryCache', () => {
@@ -379,7 +379,7 @@ describe('queryCache', () => {
       expect(query.queryCache).toBe(queryCache)
     })
 
-    test('notifyGlobalListeners passes the same instance', () => {
+    test('notifyGlobalListeners passes the same instance', async () => {
       const key = queryKey()
 
       const queryCache = new QueryCache()
@@ -387,6 +387,7 @@ describe('queryCache', () => {
       const unsubscribe = queryCache.subscribe(subscriber)
       const query = queryCache.buildQuery(key)
       query.setData('foo')
+      await sleep(1)
       expect(subscriber).toHaveBeenCalledWith(queryCache, query)
 
       unsubscribe()
diff --git a/src/core/tests/utils.test.tsx b/src/core/tests/utils.test.tsx
index da7adeb67..1e8cd5422 100644
--- a/src/core/tests/utils.test.tsx
+++ b/src/core/tests/utils.test.tsx
@@ -1,5 +1,5 @@
 import { replaceEqualDeep, deepIncludes, isPlainObject } from '../utils'
-import { setConsole, queryCache } from '..'
+import { setConsole, queryCache } from '../..'
 import { queryKey } from '../../react/tests/utils'
 
 describe('core/utils', () => {
diff --git a/src/react/tests/ReactQueryCacheProvider.test.tsx b/src/react/tests/ReactQueryCacheProvider.test.tsx
index 95e2137c3..ae3296cf9 100644
--- a/src/react/tests/ReactQueryCacheProvider.test.tsx
+++ b/src/react/tests/ReactQueryCacheProvider.test.tsx
@@ -2,8 +2,13 @@ import React, { useEffect } from 'react'
 import { render, waitFor } from '@testing-library/react'
 
 import { sleep, queryKey } from './utils'
-import { ReactQueryCacheProvider, useQuery, useQueryCache } from '..'
-import { queryCache, QueryCache } from '../../core'
+import {
+  ReactQueryCacheProvider,
+  useQuery,
+  useQueryCache,
+  queryCache,
+  QueryCache,
+} from '../..'
 
 describe('ReactQueryCacheProvider', () => {
   test('when not used, falls back to global cache', async () => {
diff --git a/src/react/tests/ReactQueryConfigProvider.test.tsx b/src/react/tests/ReactQueryConfigProvider.test.tsx
index b74c6b6f4..72c1a398b 100644
--- a/src/react/tests/ReactQueryConfigProvider.test.tsx
+++ b/src/react/tests/ReactQueryConfigProvider.test.tsx
@@ -2,8 +2,7 @@ import React, { useState } from 'react'
 import { act, fireEvent, render, waitFor } from '@testing-library/react'
 
 import { sleep, queryKey } from './utils'
-import { ReactQueryConfigProvider, useQuery } from '..'
-import { queryCache } from '../../core'
+import { ReactQueryConfigProvider, useQuery, queryCache } from '../..'
 
 describe('ReactQueryConfigProvider', () => {
   // // See https://github.com/tannerlinsley/react-query/issues/105
diff --git a/src/react/tests/ssr.test.tsx b/src/react/tests/ssr.test.tsx
index a42443494..d4dd04292 100644
--- a/src/react/tests/ssr.test.tsx
+++ b/src/react/tests/ssr.test.tsx
@@ -7,8 +7,13 @@ import React from 'react'
 import { renderToString } from 'react-dom/server'
 
 import { sleep, queryKey } from './utils'
-import { usePaginatedQuery, ReactQueryCacheProvider, useQuery } from '..'
-import { queryCache, QueryCache } from '../../core'
+import {
+  usePaginatedQuery,
+  ReactQueryCacheProvider,
+  useQuery,
+  queryCache,
+  QueryCache,
+} from '../..'
 
 describe('Server Side Rendering', () => {
   // A frozen cache does not cache any data. This is the default
diff --git a/src/react/tests/suspense.test.tsx b/src/react/tests/suspense.test.tsx
index 387fce907..551de97f1 100644
--- a/src/react/tests/suspense.test.tsx
+++ b/src/react/tests/suspense.test.tsx
@@ -3,12 +3,12 @@ import { ErrorBoundary } from 'react-error-boundary'
 import * as React from 'react'
 
 import { sleep, queryKey, mockConsoleError } from './utils'
-import { useQuery } from '..'
-import { queryCache } from '../../core'
 import {
+  useQuery,
+  queryCache,
   ReactQueryErrorResetBoundary,
   useErrorResetBoundary,
-} from '../ReactQueryErrorResetBoundary'
+} from '../..'
 
 describe("useQuery's in Suspense mode", () => {
   it('should not call the queryFn twice when used in Suspense mode', async () => {
diff --git a/src/react/tests/useInfiniteQuery.test.tsx b/src/react/tests/useInfiniteQuery.test.tsx
index ef3d417ca..9af69372b 100644
--- a/src/react/tests/useInfiniteQuery.test.tsx
+++ b/src/react/tests/useInfiniteQuery.test.tsx
@@ -2,8 +2,7 @@ import { render, waitFor, fireEvent } from '@testing-library/react'
 import * as React from 'react'
 
 import { sleep, queryKey, waitForMs, mockConsoleError } from './utils'
-import { useInfiniteQuery, useQueryCache } from '..'
-import { InfiniteQueryResult } from '../../core'
+import { useInfiniteQuery, useQueryCache, InfiniteQueryResult } from '../..'
 
 interface Result {
   items: number[]
diff --git a/src/react/tests/useIsFetching.test.tsx b/src/react/tests/useIsFetching.test.tsx
index 96f562dd5..2e7fb0a90 100644
--- a/src/react/tests/useIsFetching.test.tsx
+++ b/src/react/tests/useIsFetching.test.tsx
@@ -2,7 +2,7 @@ import { render, fireEvent, waitFor } from '@testing-library/react'
 import * as React from 'react'
 
 import { sleep, queryKey, mockConsoleError } from './utils'
-import { useQuery, useIsFetching } from '..'
+import { useQuery, useIsFetching } from '../..'
 
 describe('useIsFetching', () => {
   // See https://github.com/tannerlinsley/react-query/issues/105
diff --git a/src/react/tests/useMutation.test.tsx b/src/react/tests/useMutation.test.tsx
index 8d1e48318..b4fb38d24 100644
--- a/src/react/tests/useMutation.test.tsx
+++ b/src/react/tests/useMutation.test.tsx
@@ -1,7 +1,7 @@
 import { render, fireEvent, waitFor } from '@testing-library/react'
 import * as React from 'react'
 
-import { useMutation } from '..'
+import { useMutation } from '../..'
 import { mockConsoleError } from './utils'
 
 describe('useMutation', () => {
diff --git a/src/react/tests/usePaginatedQuery.test.tsx b/src/react/tests/usePaginatedQuery.test.tsx
index 1f18610d4..2e3f9e907 100644
--- a/src/react/tests/usePaginatedQuery.test.tsx
+++ b/src/react/tests/usePaginatedQuery.test.tsx
@@ -2,8 +2,7 @@ import { render, fireEvent, waitFor } from '@testing-library/react'
 import * as React from 'react'
 
 import { sleep, queryKey } from './utils'
-import { usePaginatedQuery } from '..'
-import { PaginatedQueryResult } from '../../core'
+import { usePaginatedQuery, PaginatedQueryResult } from '../..'
 
 describe('usePaginatedQuery', () => {
   it('should return the correct states for a successful query', async () => {
diff --git a/src/react/tests/useQuery.test.tsx b/src/react/tests/useQuery.test.tsx
index 2a1f6b1a7..ad31cebc4 100644
--- a/src/react/tests/useQuery.test.tsx
+++ b/src/react/tests/useQuery.test.tsx
@@ -9,8 +9,7 @@ import {
   mockConsoleError,
   waitForMs,
 } from './utils'
-import { useQuery } from '..'
-import { queryCache, QueryResult } from '../../core'
+import { useQuery, queryCache, QueryResult } from '../..'
 
 describe('useQuery', () => {
   it('should return the correct types', () => {
@@ -1088,6 +1087,31 @@ describe('useQuery', () => {
     expect(queryCache.getQuery(key)!.config.queryFn).toBe(queryFn1)
   })
 
+  it('should batch re-renders', async () => {
+    const key = queryKey()
+
+    let renders = 0
+
+    const queryFn = async () => {
+      await sleep(10)
+      return 'data'
+    }
+
+    function Page() {
+      useQuery(key, queryFn)
+      useQuery(key, queryFn)
+      renders++
+      return null
+    }
+
+    render(<Page />)
+
+    await waitForMs(20)
+
+    // Should be 2 instead of 3
+    expect(renders).toBe(2)
+  })
+
   // See https://github.com/tannerlinsley/react-query/issues/170
   it('should start with status idle if enabled is false', async () => {
     const key1 = queryKey()