Repository: tannerlinsley/react-query
Pull request #1775 (2021-02-10 17:44:36)

feat: add support for concurrent mode
This PR adds support for concurrent mode rendering.

Render functions will now only have these side effects:
- Creating a cache entry (should be allowed)
- Dispatching a GET request (should be allowed)

When calling a render function, it will return an optimistic result, without actually changing the observer state. The observer state will only be changed when new options are set/committed in an effect.

Unfortunately I was not able to keep the referential integrity of the result object between renders because we do not know which optimistic result will eventually get committed.

Fixes https://github.com/tannerlinsley/react-query/issues/1536

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/m6vcys1ja](https://vercel.com/tannerlinsley/react-query/m6vcys1ja)  
✅ Preview: [https://react-query-git-feature-concurrent-mode.tannerlinsley.vercel.app](https://react-query-git-feature-concurrent-mode.tannerlinsley.vercel.app)



Patch:
diff --git a/docs/src/pages/guides/infinite-queries.md b/docs/src/pages/guides/infinite-queries.md
index bec40050d..458d4c13c 100644
--- a/docs/src/pages/guides/infinite-queries.md
+++ b/docs/src/pages/guides/infinite-queries.md
@@ -16,6 +16,8 @@ When using `useInfiniteQuery`, you'll notice a few things are different:
 - A `hasPreviousPage` boolean is now available and is `true` if `getPreviousPageParam` returns a value other than `undefined`.
 - The `isFetchingNextPage` and `isFetchingPreviousPage` booleans are now available to distinguish between a background refresh state and a loading more state
 
+> Note: When using options like `initialData` or `select` in your query, make sure when you restructure your data that it still includes `data.pages` and `data.pageParams` properties, otherwise your changes will be overwritten by the query in its return!
+
 ## Example
 
 Let's assume we have an API that returns pages of `projects` 3 at a time based on a `cursor` index along with a cursor that can be used to fetch the next group of projects:
@@ -152,3 +154,19 @@ queryClient.setQueryData('projects', data => ({
   pageParams: data.pageParams.slice(1),
 }))
 ```
+
+Manually removing a single of value from an individual page:
+
+```js
+const newPagesArray = []
+oldPagesArray?.pages.forEach(page => {
+  const newData = page.data.filter(val => val.id !== updatedId)
+  newPagesArray.push({ data: newData, pageParam: page.pageParam })
+})
+queryClient.setQueryData('projects', data => ({
+  pages: newPagesArray,
+  pageParams: data.pageParams,
+}))
+```
+
+Make sure to keep the same data structure of pages and pageParams!
diff --git a/docs/src/pages/guides/placeholder-query-data.md b/docs/src/pages/guides/placeholder-query-data.md
index 8f1c7fc4c..32af6ed92 100644
--- a/docs/src/pages/guides/placeholder-query-data.md
+++ b/docs/src/pages/guides/placeholder-query-data.md
@@ -28,15 +28,12 @@ function Todos() {
 
 ### Placeholder Data as a Function
 
-If the process for accessing a query's placeholder data is intensive or just not something you want to perform on every render, you can pass a function as the `placeholderData` value. This function will be executed only once when the query is placeholderized, saving you precious memory and/or CPU:
+If the process for accessing a query's placeholder data is intensive or just not something you want to perform on every render, you can memoize the value or pass a memoized function as the `placeholderData` value:
 
 ```js
 function Todos() {
-  const result = useQuery('todos', () => fetch('/todos'), {
-    placeholderData: () => {
-      return generateFakeTodos()
-    },
-  })
+  const placeholderData = useMemo(() => generateFakeTodos(), [])
+  const result = useQuery('todos', () => fetch('/todos'), { placeholderData })
 }
 ```
 
diff --git a/docs/src/pages/guides/query-functions.md b/docs/src/pages/guides/query-functions.md
index a7a5fbc68..5136fae77 100644
--- a/docs/src/pages/guides/query-functions.md
+++ b/docs/src/pages/guides/query-functions.md
@@ -36,11 +36,11 @@ While most utilities like `axios` or `graphql-request` automatically throw error
 
 ```js
 useQuery(['todos', todoId], async () => {
-  const { ok, json } = await fetch('/todos/' + todoId)
-  if (!ok) {
+  const response = await fetch('/todos/' + todoId)
+  if (!response.ok) {
     throw new Error('Network response was not ok')
   }
-  return json()
+  return response.json()
 })
 ```
 
diff --git a/docs/src/pages/guides/window-focus-refetching.md b/docs/src/pages/guides/window-focus-refetching.md
index a385f0d27..9b0de8f26 100644
--- a/docs/src/pages/guides/window-focus-refetching.md
+++ b/docs/src/pages/guides/window-focus-refetching.md
@@ -67,15 +67,11 @@ Instead of event listeners on `window`, React Native provides focus information
 import { AppState } from 'react-native'
 import { focusManager } from 'react-query'
 
-focusManager.setEventListener(setFocus => {
-  const handleAppStateChange = appState => {
-    setFocus(appState === 'active')
-  }
-
-  AppState.addEventListener('change', handleAppStateChange)
+focusManager.setEventListener(handleFocus => {
+  AppState.addEventListener('change', handleFocus)
 
   return () => {
-    AppState.removeEventListener('change', handleAppStateChange)
+   AppState.removeEventListener('change', handleFocus)
   }
 })
 ```
diff --git a/docs/src/pages/reference/QueryClient.md b/docs/src/pages/reference/QueryClient.md
index 5f86b4a9e..cd3d7e340 100644
--- a/docs/src/pages/reference/QueryClient.md
+++ b/docs/src/pages/reference/QueryClient.md
@@ -117,7 +117,7 @@ The options for `fetchInfiniteQuery` are exactly the same as those of [`fetchQue
 
 ## `queryClient.prefetchQuery`
 
-`prefetchQuery` is an asynchronous method that can be used to prefetch a query before it is needed or rendered with `useQuery` and friends. The method works the same as `fetchQuery` except that is will not throw or return any data.
+`prefetchQuery` is an asynchronous method that can be used to prefetch a query before it is needed or rendered with `useQuery` and friends. The method works the same as `fetchQuery` except that it will not throw or return any data.
 
 ```js
 await queryClient.prefetchQuery(queryKey, queryFn)
diff --git a/docs/src/pages/reference/useInfiniteQuery.md b/docs/src/pages/reference/useInfiniteQuery.md
index c59b69ff9..77d9198b7 100644
--- a/docs/src/pages/reference/useInfiniteQuery.md
+++ b/docs/src/pages/reference/useInfiniteQuery.md
@@ -24,12 +24,13 @@ const {
 The options for `useInfiniteQuery` are identical to the [`useQuery` hook](/reference/useQuery) with the addition of the following:
 
 - `queryFn: (context: QueryFunctionContext) => Promise<TData>`
-  - **Required, but only if no default query function has been defined**
+  - **Required, but only if no default query function has been defined** [`defaultQueryFn`](/guides/default-query-function)
   - The function that the query will use to request data.
   - Receives a `QueryFunctionContext` object with the following variables:
     - `queryKey: QueryKey`
     - `pageParam: unknown | undefined`
   - Must return a promise that will either resolves data or throws an error.
+  - Make sure you return the data *and* the `pageParam` if needed for use in the props below.
 - `getNextPageParam: (lastPage, allPages) => unknown | undefined`
   - When new data is received for this query, this function receives both the last page of the infinite list of data and the full array of all pages.
   - It should return a **single variable** that will be passed as the last optional parameter to your query function.
diff --git a/docs/src/pages/reference/useQuery.md b/docs/src/pages/reference/useQuery.md
index b7ecab55c..ef71b1c0f 100644
--- a/docs/src/pages/reference/useQuery.md
+++ b/docs/src/pages/reference/useQuery.md
@@ -69,7 +69,7 @@ const result = useQuery({
   - The query key will be hashed into a stable hash. See [Query Keys](../guides/query-keys) for more information.
   - The query will automatically update when this key changes (as long as `enabled` is not set to `false`).
 - `queryFn: (context: QueryFunctionContext) => Promise<TData>`
-  - **Required, but only if no default query function has been defined**
+  - **Required, but only if no default query function has been defined** See [Default Query Function](../guides/default-query-function) for more information.
   - The function that the query will use to request data.
   - Receives a `QueryFunctionContext` object with the following variables:
     - `queryKey: QueryKey`
diff --git a/src/core/infiniteQueryObserver.ts b/src/core/infiniteQueryObserver.ts
index 959572a08..4b00be998 100644
--- a/src/core/infiniteQueryObserver.ts
+++ b/src/core/infiniteQueryObserver.ts
@@ -12,6 +12,7 @@ import {
   hasPreviousPage,
   infiniteQueryBehavior,
 } from './infiniteQueryBehavior'
+import { Query } from './query'
 
 type InfiniteQueryObserverListener<TData, TError> = (
   result: InfiniteQueryObserverResult<TData, TError>
@@ -98,9 +99,17 @@ export class InfiniteQueryObserver<
     })
   }
 
-  protected getNewResult(): InfiniteQueryObserverResult<TData, TError> {
+  protected createResult(
+    query: Query<TQueryFnData, TError, InfiniteData<TQueryData>>,
+    options: InfiniteQueryObserverOptions<
+      TQueryFnData,
+      TError,
+      TData,
+      TQueryData
+    >
+  ): InfiniteQueryObserverResult<TData, TError> {
     const { state } = this.getCurrentQuery()
-    const result = super.getNewResult()
+    const result = super.createResult(query, options)
     return {
       ...result,
       fetchNextPage: this.fetchNextPage,
diff --git a/src/core/mutationObserver.ts b/src/core/mutationObserver.ts
index da52a321c..836de9705 100644
--- a/src/core/mutationObserver.ts
+++ b/src/core/mutationObserver.ts
@@ -7,7 +7,6 @@ import type {
   MutationObserverResult,
   MutationObserverOptions,
 } from './types'
-import { getStatusProps } from './utils'
 
 // TYPES
 
@@ -132,7 +131,10 @@ export class MutationObserver<
 
     this.currentResult = {
       ...state,
-      ...getStatusProps(state.status),
+      isLoading: state.status === 'loading',
+      isSuccess: state.status === 'success',
+      isError: state.status === 'error',
+      isIdle: state.status === 'idle',
       mutate: this.mutate,
       reset: this.reset,
     }
diff --git a/src/core/queriesObserver.ts b/src/core/queriesObserver.ts
index 39c3a05ee..b18b6d275 100644
--- a/src/core/queriesObserver.ts
+++ b/src/core/queriesObserver.ts
@@ -2,7 +2,7 @@ import { difference, getQueryKeyHashFn, replaceAt } from './utils'
 import { notifyManager } from './notifyManager'
 import type { QueryObserverOptions, QueryObserverResult } from './types'
 import type { QueryClient } from './queryClient'
-import { QueryObserver } from './queryObserver'
+import { NotifyOptions, QueryObserver } from './queryObserver'
 import { Subscribable } from './subscribable'
 
 type QueriesObserverListener = (result: QueryObserverResult[]) => void
@@ -20,9 +20,7 @@ export class QueriesObserver extends Subscribable<QueriesObserverListener> {
     this.queries = queries || []
     this.result = []
     this.observers = []
-
-    // Subscribe to queries
-    this.updateObservers()
+    this.setQueries(this.queries)
   }
 
   protected onSubscribe(): void {
@@ -48,21 +46,21 @@ export class QueriesObserver extends Subscribable<QueriesObserverListener> {
     })
   }
 
-  setQueries(queries: QueryObserverOptions[]): void {
+  setQueries(
+    queries: QueryObserverOptions[],
+    notifyOptions?: NotifyOptions
+  ): void {
     this.queries = queries
-    this.updateObservers()
+    this.updateObservers(notifyOptions)
   }
 
   getCurrentResult(): QueryObserverResult[] {
     return this.result
   }
 
-  private updateObservers(): void {
-    let hasIndexChange = false
-
-    const prevObservers = this.observers
-    const newObservers = this.queries.map((options, i) => {
-      let observer: QueryObserver | undefined = prevObservers[i]
+  getOptimisticResult(queries: QueryObserverOptions[]): QueryObserverResult[] {
+    return queries.map((options, i) => {
+      let observer: QueryObserver | undefined = this.observers[i]
 
       const defaultedOptions = this.client.defaultQueryObserverOptions(options)
       const hashFn = getQueryKeyHashFn(defaultedOptions)
@@ -72,42 +70,74 @@ export class QueriesObserver extends Subscribable<QueriesObserverListener> {
         !observer ||
         observer.getCurrentQuery().queryHash !== defaultedOptions.queryHash
       ) {
-        hasIndexChange = true
-        observer = prevObservers.find(
+        observer = this.observers.find(
           x => x.getCurrentQuery().queryHash === defaultedOptions.queryHash
         )
       }
 
-      if (observer) {
-        observer.setOptions(defaultedOptions)
-        return observer
+      if (!observer) {
+        observer = new QueryObserver(this.client, defaultedOptions)
       }
 
-      return new QueryObserver(this.client, defaultedOptions)
+      return observer.getOptimisticResult(defaultedOptions)
     })
+  }
 
-    if (prevObservers.length === newObservers.length && !hasIndexChange) {
-      return
-    }
+  private updateObservers(notifyOptions?: NotifyOptions): void {
+    notifyManager.batch(() => {
+      let hasIndexChange = false
 
-    this.observers = newObservers
-    this.result = newObservers.map(observer => observer.getCurrentResult())
+      const prevObservers = this.observers
+      const newObservers = this.queries.map((options, i) => {
+        let observer: QueryObserver | undefined = prevObservers[i]
 
-    if (!this.listeners.length) {
-      return
-    }
+        const defaultedOptions = this.client.defaultQueryObserverOptions(
+          options
+        )
+        const hashFn = getQueryKeyHashFn(defaultedOptions)
+        defaultedOptions.queryHash = hashFn(defaultedOptions.queryKey!)
+
+        if (
+          !observer ||
+          observer.getCurrentQuery().queryHash !== defaultedOptions.queryHash
+        ) {
+          hasIndexChange = true
+          observer = prevObservers.find(
+            x => x.getCurrentQuery().queryHash === defaultedOptions.queryHash
+          )
+        }
+
+        if (observer) {
+          observer.setOptions(defaultedOptions, notifyOptions)
+          return observer
+        }
+
+        return new QueryObserver(this.client, defaultedOptions)
+      })
 
-    difference(prevObservers, newObservers).forEach(observer => {
-      observer.destroy()
-    })
+      if (prevObservers.length === newObservers.length && !hasIndexChange) {
+        return
+      }
 
-    difference(newObservers, prevObservers).forEach(observer => {
-      observer.subscribe(result => {
-        this.onUpdate(observer, result)
+      this.observers = newObservers
+      this.result = newObservers.map(observer => observer.getCurrentResult())
+
+      if (!this.listeners.length) {
+        return
+      }
+
+      difference(prevObservers, newObservers).forEach(observer => {
+        observer.destroy()
       })
-    })
 
-    this.notify()
+      difference(newObservers, prevObservers).forEach(observer => {
+        observer.subscribe(result => {
+          this.onUpdate(observer, result)
+        })
+      })
+
+      this.notify()
+    })
   }
 
   private onUpdate(observer: QueryObserver, result: QueryObserverResult): void {
diff --git a/src/core/query.ts b/src/core/query.ts
index 8e6aef538..0be2ae447 100644
--- a/src/core/query.ts
+++ b/src/core/query.ts
@@ -260,7 +260,7 @@ export class Query<
   }
 
   onFocus(): void {
-    const observer = this.observers.find(x => x.willFetchOnWindowFocus())
+    const observer = this.observers.find(x => x.shouldFetchOnWindowFocus())
 
     if (observer) {
       observer.refetch()
@@ -271,7 +271,7 @@ export class Query<
   }
 
   onOnline(): void {
-    const observer = this.observers.find(x => x.willFetchOnReconnect())
+    const observer = this.observers.find(x => x.shouldFetchOnReconnect())
 
     if (observer) {
       observer.refetch()
@@ -328,7 +328,7 @@ export class Query<
     options?: QueryOptions<TQueryFnData, TError, TData>,
     fetchOptions?: FetchOptions
   ): Promise<TData> {
-    if (this.state.isFetching)
+    if (this.state.isFetching) {
       if (this.state.dataUpdatedAt && fetchOptions?.cancelRefetch) {
         // Silently cancel current fetch if the user wants to cancel refetches
         this.cancel({ silent: true })
@@ -336,6 +336,7 @@ export class Query<
         // Return current promise if we are already fetching
         return this.promise
       }
+    }
 
     // Update config if passed, otherwise the config from the last execution is used
     if (options) {
diff --git a/src/core/queryCache.ts b/src/core/queryCache.ts
index e323e1d6d..a0652fe6d 100644
--- a/src/core/queryCache.ts
+++ b/src/core/queryCache.ts
@@ -42,9 +42,8 @@ export class QueryCache extends Subscribable<QueryCacheListener> {
     options: QueryOptions<TQueryFnData, TError, TData>,
     state?: QueryState<TData, TError>
   ): Query<TQueryFnData, TError, TData> {
-    const hashFn = getQueryKeyHashFn(options)
     const queryKey = options.queryKey!
-    const queryHash = options.queryHash ?? hashFn(queryKey)
+    const queryHash = options.queryHash ?? getQueryKeyHashFn(options)(queryKey)
     let query = this.get<TQueryFnData, TError, TData>(queryHash)
 
     if (!query) {
diff --git a/src/core/queryClient.ts b/src/core/queryClient.ts
index b399f6c05..a855b8064 100644
--- a/src/core/queryClient.ts
+++ b/src/core/queryClient.ts
@@ -6,6 +6,7 @@ import {
   parseFilterArgs,
   parseQueryArgs,
   partialMatchKey,
+  getQueryKeyHashFn,
 } from './utils'
 import type {
   DefaultOptions,
@@ -446,12 +447,21 @@ export class QueryClient {
     if (options?._defaulted) {
       return options
     }
-    return {
+
+    const defaultedOptions = {
       ...this.defaultOptions.queries,
       ...this.getQueryDefaults(options?.queryKey),
       ...options,
       _defaulted: true,
     } as T
+
+    if (!defaultedOptions.queryHash && defaultedOptions.queryKey) {
+      defaultedOptions.queryHash = getQueryKeyHashFn(defaultedOptions)(
+        defaultedOptions.queryKey
+      )
+    }
+
+    return defaultedOptions
   }
 
   defaultQueryObserverOptions<
diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts
index 6cbd29c92..5c698f3ce 100644
--- a/src/core/queryObserver.ts
+++ b/src/core/queryObserver.ts
@@ -1,5 +1,4 @@
 import {
-  getStatusProps,
   isServer,
   isValidTimeout,
   noop,
@@ -27,7 +26,7 @@ type QueryObserverListener<TData, TError> = (
   result: QueryObserverResult<TData, TError>
 ) => void
 
-interface NotifyOptions {
+export interface NotifyOptions {
   cache?: boolean
   listeners?: boolean
   onError?: boolean
@@ -48,21 +47,19 @@ export class QueryObserver<
 
   private client: QueryClient
   private currentQuery!: Query<TQueryFnData, TError, TQueryData>
+  private currentQueryInitialState!: QueryState<TQueryData, TError>
   private currentResult!: QueryObserverResult<TData, TError>
   private currentResultState?: QueryState<TQueryData, TError>
-  private previousOptions?: QueryObserverOptions<
+  private currentResultOptions?: QueryObserverOptions<
     TQueryFnData,
     TError,
     TData,
     TQueryData
   >
   private previousQueryResult?: QueryObserverResult<TData, TError>
-  private initialDataUpdateCount: number
-  private initialErrorUpdateCount: number
   private staleTimeoutId?: number
   private refetchIntervalId?: number
   private trackedProps!: Array<keyof QueryObserverResult>
-  private trackedCurrentResult!: QueryObserverResult<TData, TError>
 
   constructor(
     client: QueryClient,
@@ -72,8 +69,6 @@ export class QueryObserver<
 
     this.client = client
     this.options = options
-    this.initialDataUpdateCount = 0
-    this.initialErrorUpdateCount = 0
     this.trackedProps = []
     this.bindMethods()
     this.setOptions(options)
@@ -86,15 +81,12 @@ export class QueryObserver<
 
   protected onSubscribe(): void {
     if (this.listeners.length === 1) {
-      this.updateQuery()
-
       this.currentQuery.addObserver(this)
 
-      if (this.willFetchOnMount()) {
+      if (shouldFetchOnMount(this.currentQuery, this.options)) {
         this.executeFetch()
       }
 
-      this.updateResult()
       this.updateTimers()
     }
   }
@@ -105,52 +97,12 @@ export class QueryObserver<
     }
   }
 
-  willLoadOnMount(): boolean {
-    return (
-      this.options.enabled !== false &&
-      !this.currentQuery.state.dataUpdatedAt &&
-      !(
-        this.currentQuery.state.status === 'error' &&
-        this.options.retryOnMount === false
-      )
-    )
-  }
-
-  willRefetchOnMount(): boolean {
-    return (
-      this.options.enabled !== false &&
-      this.currentQuery.state.dataUpdatedAt > 0 &&
-      (this.options.refetchOnMount === 'always' ||
-        (this.options.refetchOnMount !== false && this.isStale()))
-    )
-  }
-
-  willFetchOnMount(): boolean {
-    return this.willLoadOnMount() || this.willRefetchOnMount()
-  }
-
-  willFetchOnReconnect(): boolean {
-    return (
-      this.options.enabled !== false &&
-      (this.options.refetchOnReconnect === 'always' ||
-        (this.options.refetchOnReconnect !== false && this.isStale()))
-    )
-  }
-
-  willFetchOnWindowFocus(): boolean {
-    return (
-      this.options.enabled !== false &&
-      (this.options.refetchOnWindowFocus === 'always' ||
-        (this.options.refetchOnWindowFocus !== false && this.isStale()))
-    )
+  shouldFetchOnReconnect(): boolean {
+    return shouldFetchOnReconnect(this.currentQuery, this.options)
   }
 
-  private willFetchOptionally(): boolean {
-    return this.options.enabled !== false && this.isStale()
-  }
-
-  private isStale(): boolean {
-    return this.currentQuery.isStaleByTime(this.options.staleTime)
+  shouldFetchOnWindowFocus(): boolean {
+    return shouldFetchOnWindowFocus(this.currentQuery, this.options)
   }
 
   destroy(): void {
@@ -160,9 +112,12 @@ export class QueryObserver<
   }
 
   setOptions(
-    options?: QueryObserverOptions<TQueryFnData, TError, TData, TQueryData>
+    options?: QueryObserverOptions<TQueryFnData, TError, TData, TQueryData>,
+    notifyOptions?: NotifyOptions
   ): void {
-    this.previousOptions = this.options
+    const prevOptions = this.options
+    const prevQuery = this.currentQuery
+
     this.options = this.client.defaultQueryObserverOptions(options)
 
     if (
@@ -174,84 +129,89 @@ export class QueryObserver<
 
     // Keep previous query key if the user does not supply one
     if (!this.options.queryKey) {
-      this.options.queryKey = this.previousOptions.queryKey
+      this.options.queryKey = prevOptions.queryKey
     }
 
-    const didUpdateQuery = this.updateQuery()
-
-    let optionalFetch
-    let updateResult
-    let updateStaleTimeout
-    let updateRefetchInterval
+    this.updateQuery()
 
-    // If we subscribed to a new query, optionally fetch and update result and timers
-    if (didUpdateQuery) {
-      optionalFetch = true
-      updateResult = true
-      updateStaleTimeout = true
-      updateRefetchInterval = true
-    }
+    const mounted = this.hasListeners()
 
-    // Optionally fetch if the query became enabled
+    // Fetch if there are subscribers
     if (
-      this.options.enabled !== false &&
-      this.previousOptions.enabled === false
+      mounted &&
+      shouldFetchOptionally(
+        this.currentQuery,
+        prevQuery,
+        this.options,
+        prevOptions
+      )
     ) {
-      optionalFetch = true
+      this.executeFetch()
     }
 
-    // Update result if the select function changed
-    if (this.options.select !== this.previousOptions.select) {
-      updateResult = true
-    }
+    // Update result
+    this.updateResult(notifyOptions)
 
     // Update stale interval if needed
     if (
-      this.options.enabled !== this.previousOptions.enabled ||
-      this.options.staleTime !== this.previousOptions.staleTime
+      mounted &&
+      (this.currentQuery !== prevQuery ||
+        this.options.enabled !== prevOptions.enabled ||
+        this.options.staleTime !== prevOptions.staleTime)
     ) {
-      updateStaleTimeout = true
+      this.updateStaleTimeout()
     }
 
     // Update refetch interval if needed
     if (
-      this.options.enabled !== this.previousOptions.enabled ||
-      this.options.refetchInterval !== this.previousOptions.refetchInterval
+      mounted &&
+      (this.currentQuery !== prevQuery ||
+        this.options.enabled !== prevOptions.enabled ||
+        this.options.refetchInterval !== prevOptions.refetchInterval)
     ) {
-      updateRefetchInterval = true
-    }
-
-    // Fetch only if there are subscribers
-    if (this.hasListeners()) {
-      if (optionalFetch) {
-        this.optionalFetch()
-      }
+      this.updateRefetchInterval()
     }
+  }
 
-    if (updateResult) {
-      this.updateResult()
-    }
+  getOptimisticResult(
+    options: QueryObserverOptions<TQueryFnData, TError, TData, TQueryData>
+  ): QueryObserverResult<TData, TError> {
+    const defaultedOptions = this.client.defaultQueryObserverOptions(options)
 
-    // Update intervals only if there are subscribers
-    if (this.hasListeners()) {
-      if (updateStaleTimeout) {
-        this.updateStaleTimeout()
-      }
-      if (updateRefetchInterval) {
-        this.updateRefetchInterval()
-      }
-    }
+    const query = this.client
+      .getQueryCache()
+      .build(
+        this.client,
+        defaultedOptions as QueryOptions<TQueryFnData, TError, TQueryData>
+      )
 
-    // Reset previous options after all code related to option changes has run
-    this.previousOptions = this.options
+    return this.createResult(query, defaultedOptions)
   }
 
   getCurrentResult(): QueryObserverResult<TData, TError> {
     return this.currentResult
   }
 
-  getTrackedCurrentResult(): QueryObserverResult<TData, TError> {
-    return this.trackedCurrentResult
+  trackResult(
+    result: QueryObserverResult<TData, TError>
+  ): QueryObserverResult<TData, TError> {
+    const trackedResult = {} as QueryObserverResult<TData, TError>
+
+    Object.keys(result).forEach(key => {
+      Object.defineProperty(trackedResult, key, {
+        configurable: false,
+        enumerable: true,
+        get: () => {
+          const typedKey = key as keyof QueryObserverResult
+          if (!this.trackedProps.includes(typedKey)) {
+            this.trackedProps.push(typedKey)
+          }
+          return result[typedKey]
+        },
+      })
+    })
+
+    return trackedResult
   }
 
   getNextResult(
@@ -294,12 +254,6 @@ export class QueryObserver<
     })
   }
 
-  private optionalFetch(): void {
-    if (this.willFetchOptionally()) {
-      this.executeFetch()
-    }
-  }
-
   private executeFetch(
     fetchOptions?: ObserverFetchOptions
   ): Promise<TQueryData | undefined> {
@@ -387,50 +341,72 @@ export class QueryObserver<
     this.refetchIntervalId = undefined
   }
 
-  protected getNewResult(): QueryObserverResult<TData, TError> {
-    const { state } = this.currentQuery
-    let { isFetching, status } = state
+  protected createResult(
+    query: Query<TQueryFnData, TError, TQueryData>,
+    options: QueryObserverOptions<TQueryFnData, TError, TData, TQueryData>
+  ): QueryObserverResult<TData, TError> {
+    const prevQuery = this.currentQuery
+    const prevOptions = this.options
+    const prevResult = this.currentResult
+    const prevResultState = this.currentResultState
+    const prevResultOptions = this.currentResultOptions
+    const queryChange = query !== prevQuery
+    const queryInitialState = queryChange
+      ? query.state
+      : this.currentQueryInitialState
+    const prevQueryResult = queryChange
+      ? this.currentResult
+      : this.previousQueryResult
+
+    const { state } = query
+    let { dataUpdatedAt, error, errorUpdatedAt, isFetching, status } = state
     let isPreviousData = false
     let isPlaceholderData = false
     let data: TData | undefined
-    let dataUpdatedAt = state.dataUpdatedAt
-    let error = state.error
-    let errorUpdatedAt = state.errorUpdatedAt
-
-    // Optimistically set status to loading if we will start fetching
-    if (!this.hasListeners() && this.willFetchOnMount()) {
-      isFetching = true
-      if (!dataUpdatedAt) {
-        status = 'loading'
+
+    // Optimistically set result in fetching state if needed
+    if (options.optimisticResults) {
+      const mounted = this.hasListeners()
+
+      const fetchOnMount = !mounted && shouldFetchOnMount(query, options)
+
+      const fetchOptionally =
+        mounted && shouldFetchOptionally(query, prevQuery, options, prevOptions)
+
+      if (fetchOnMount || fetchOptionally) {
+        isFetching = true
+        if (!dataUpdatedAt) {
+          status = 'loading'
+        }
       }
     }
 
     // Keep previous data if needed
     if (
-      this.options.keepPreviousData &&
+      options.keepPreviousData &&
       !state.dataUpdateCount &&
-      this.previousQueryResult?.isSuccess &&
+      prevQueryResult?.isSuccess &&
       status !== 'error'
     ) {
-      data = this.previousQueryResult.data
-      dataUpdatedAt = this.previousQueryResult.dataUpdatedAt
-      status = this.previousQueryResult.status
+      data = prevQueryResult.data
+      dataUpdatedAt = prevQueryResult.dataUpdatedAt
+      status = prevQueryResult.status
       isPreviousData = true
     }
     // Select data if needed
-    else if (this.options.select && typeof state.data !== 'undefined') {
-      // Use the previous select result if the query data and select function did not change
+    else if (options.select && typeof state.data !== 'undefined') {
+      // Memoize select result
       if (
-        this.currentResult &&
-        state.data === this.currentResultState?.data &&
-        this.options.select === this.previousOptions?.select
+        prevResult &&
+        state.data === prevResultState?.data &&
+        options.select === prevResultOptions?.select
       ) {
-        data = this.currentResult.data
+        data = prevResult.data
       } else {
         try {
-          data = this.options.select(state.data)
-          if (this.options.structuralSharing !== false) {
-            data = replaceEqualDeep(this.currentResult?.data, data)
+          data = options.select(state.data)
+          if (options.structuralSharing !== false) {
+            data = replaceEqualDeep(prevResult?.data, data)
           }
         } catch (selectError) {
           getLogger().error(selectError)
@@ -447,14 +423,25 @@ export class QueryObserver<
 
     // Show placeholder data if needed
     if (
-      typeof this.options.placeholderData !== 'undefined' &&
+      typeof options.placeholderData !== 'undefined' &&
       typeof data === 'undefined' &&
       status === 'loading'
     ) {
-      const placeholderData =
-        typeof this.options.placeholderData === 'function'
-          ? (this.options.placeholderData as PlaceholderDataFunction<TData>)()
-          : this.options.placeholderData
+      let placeholderData
+
+      // Memoize placeholder data
+      if (
+        prevResult?.isPlaceholderData &&
+        options.placeholderData === prevResultOptions?.placeholderData
+      ) {
+        placeholderData = prevResult.data
+      } else {
+        placeholderData =
+          typeof options.placeholderData === 'function'
+            ? (options.placeholderData as PlaceholderDataFunction<TData>)()
+            : options.placeholderData
+      }
+
       if (typeof placeholderData !== 'undefined') {
         status = 'success'
         data = placeholderData
@@ -463,7 +450,11 @@ export class QueryObserver<
     }
 
     const result: QueryObserverBaseResult<TData, TError> = {
-      ...getStatusProps(status),
+      status,
+      isLoading: status === 'loading',
+      isSuccess: status === 'success',
+      isError: status === 'error',
+      isIdle: status === 'idle',
       data,
       dataUpdatedAt,
       error,
@@ -471,14 +462,14 @@ export class QueryObserver<
       failureCount: state.fetchFailureCount,
       isFetched: state.dataUpdateCount > 0 || state.errorUpdateCount > 0,
       isFetchedAfterMount:
-        state.dataUpdateCount > this.initialDataUpdateCount ||
-        state.errorUpdateCount > this.initialErrorUpdateCount,
+        state.dataUpdateCount > queryInitialState.dataUpdateCount ||
+        state.errorUpdateCount > queryInitialState.errorUpdateCount,
       isFetching,
       isLoadingError: status === 'error' && state.dataUpdatedAt === 0,
       isPlaceholderData,
       isPreviousData,
       isRefetchError: status === 'error' && state.dataUpdatedAt !== 0,
-      isStale: this.isStale(),
+      isStale: isStale(query, options),
       refetch: this.refetch,
       remove: this.remove,
     }
@@ -487,109 +478,69 @@ export class QueryObserver<
   }
 
   private shouldNotifyListeners(
-    prevResult: QueryObserverResult | undefined,
-    result: QueryObserverResult
+    result: QueryObserverResult,
+    prevResult?: QueryObserverResult
   ): boolean {
-    const { notifyOnChangeProps, notifyOnChangePropsExclusions } = this.options
+    if (!prevResult) {
+      return true
+    }
 
-    if (prevResult === result) {
+    if (result === prevResult) {
       return false
     }
 
-    if (!prevResult) {
+    const { notifyOnChangeProps, notifyOnChangePropsExclusions } = this.options
+
+    if (!notifyOnChangeProps && !notifyOnChangePropsExclusions) {
       return true
     }
 
-    if (!notifyOnChangeProps && !notifyOnChangePropsExclusions) {
+    if (notifyOnChangeProps === 'tracked' && !this.trackedProps.length) {
       return true
     }
 
-    const keys = Object.keys(result)
     const includedProps =
       notifyOnChangeProps === 'tracked'
         ? this.trackedProps
         : notifyOnChangeProps
 
-    for (let i = 0; i < keys.length; i++) {
-      const key = keys[i] as keyof QueryObserverResult
-      const changed = prevResult[key] !== result[key]
+    return Object.keys(result).some(key => {
+      const typedKey = key as keyof QueryObserverResult
+      const changed = result[typedKey] !== prevResult[typedKey]
       const isIncluded = includedProps?.some(x => x === key)
       const isExcluded = notifyOnChangePropsExclusions?.some(x => x === key)
-
-      if (changed) {
-        if (notifyOnChangePropsExclusions && isExcluded) {
-          continue
-        }
-
-        if (
-          !notifyOnChangeProps ||
-          isIncluded ||
-          (notifyOnChangeProps === 'tracked' && this.trackedProps.length === 0)
-        ) {
-          return true
-        }
-      }
-    }
-
-    return false
+      return changed && !isExcluded && (!includedProps || isIncluded)
+    })
   }
 
-  private updateResult(action?: Action<TData, TError>): void {
+  updateResult(notifyOptions?: NotifyOptions): void {
     const prevResult = this.currentResult as
       | QueryObserverResult<TData, TError>
       | undefined
 
-    const result = this.getNewResult()
-
-    // Keep reference to the current state on which the current result is based on
+    this.currentResult = this.createResult(this.currentQuery, this.options)
     this.currentResultState = this.currentQuery.state
+    this.currentResultOptions = this.options
 
-    // Only update if something has changed
-    if (shallowEqualObjects(result, prevResult)) {
+    // Only notify if something has changed
+    if (shallowEqualObjects(this.currentResult, prevResult)) {
       return
     }
 
-    this.currentResult = result
-
-    if (this.options.notifyOnChangeProps === 'tracked') {
-      const addTrackedProps = (prop: keyof QueryObserverResult) => {
-        if (!this.trackedProps.includes(prop)) {
-          this.trackedProps.push(prop)
-        }
-      }
-      this.trackedCurrentResult = {} as QueryObserverResult<TData, TError>
-
-      Object.keys(result).forEach(key => {
-        Object.defineProperty(this.trackedCurrentResult, key, {
-          configurable: false,
-          enumerable: true,
-          get() {
-            addTrackedProps(key as keyof QueryObserverResult)
-            return result[key as keyof QueryObserverResult]
-          },
-        })
-      })
-    }
-
     // Determine which callbacks to trigger
-    const notifyOptions: NotifyOptions = { cache: true }
-
-    if (action?.type === 'success') {
-      notifyOptions.onSuccess = true
-    } else if (action?.type === 'error') {
-      notifyOptions.onError = true
-    }
+    const defaultNotifyOptions: NotifyOptions = { cache: true }
 
-    if (this.shouldNotifyListeners(prevResult, result)) {
-      notifyOptions.listeners = true
+    if (
+      notifyOptions?.listeners !== false &&
+      this.shouldNotifyListeners(this.currentResult, prevResult)
+    ) {
+      defaultNotifyOptions.listeners = true
     }
 
-    this.notify(notifyOptions)
+    this.notify({ ...defaultNotifyOptions, ...notifyOptions })
   }
 
-  private updateQuery(): boolean {
-    const prevQuery = this.currentQuery
-
+  private updateQuery(): void {
     const query = this.client
       .getQueryCache()
       .build(
@@ -597,25 +548,32 @@ export class QueryObserver<
         this.options as QueryOptions<TQueryFnData, TError, TQueryData>
       )
 
-    if (query === prevQuery) {
-      return false
+    if (query === this.currentQuery) {
+      return
     }
 
-    this.previousQueryResult = this.currentResult
+    const prevQuery = this.currentQuery
     this.currentQuery = query
-    this.initialDataUpdateCount = query.state.dataUpdateCount
-    this.initialErrorUpdateCount = query.state.errorUpdateCount
+    this.currentQueryInitialState = query.state
+    this.previousQueryResult = this.currentResult
 
     if (this.hasListeners()) {
       prevQuery?.removeObserver(this)
-      this.currentQuery.addObserver(this)
+      query.addObserver(this)
     }
-
-    return true
   }
 
   onQueryUpdate(action: Action<TData, TError>): void {
-    this.updateResult(action)
+    const notifyOptions: NotifyOptions = {}
+
+    if (action.type === 'success') {
+      notifyOptions.onSuccess = true
+    } else if (action.type === 'error') {
+      notifyOptions.onError = true
+    }
+
+    this.updateResult(notifyOptions)
+
     if (this.hasListeners()) {
       this.updateTimers()
     }
@@ -646,3 +604,77 @@ export class QueryObserver<
     })
   }
 }
+
+function shouldLoadOnMount(
+  query: Query<any, any>,
+  options: QueryObserverOptions<any, any>
+): boolean {
+  return (
+    options.enabled !== false &&
+    !query.state.dataUpdatedAt &&
+    !(query.state.status === 'error' && options.retryOnMount === false)
+  )
+}
+
+function shouldRefetchOnMount(
+  query: Query<any, any>,
+  options: QueryObserverOptions<any, any>
+): boolean {
+  return (
+    options.enabled !== false &&
+    query.state.dataUpdatedAt > 0 &&
+    (options.refetchOnMount === 'always' ||
+      (options.refetchOnMount !== false && isStale(query, options)))
+  )
+}
+
+function shouldFetchOnMount(
+  query: Query<any, any>,
+  options: QueryObserverOptions<any, any>
+): boolean {
+  return (
+    shouldLoadOnMount(query, options) || shouldRefetchOnMount(query, options)
+  )
+}
+
+function shouldFetchOnReconnect(
+  query: Query<any, any>,
+  options: QueryObserverOptions<any, any>
+): boolean {
+  return (
+    options.enabled !== false &&
+    (options.refetchOnReconnect === 'always' ||
+      (options.refetchOnReconnect !== false && isStale(query, options)))
+  )
+}
+
+function shouldFetchOnWindowFocus(
+  query: Query<any, any>,
+  options: QueryObserverOptions<any, any>
+): boolean {
+  return (
+    options.enabled !== false &&
+    (options.refetchOnWindowFocus === 'always' ||
+      (options.refetchOnWindowFocus !== false && isStale(query, options)))
+  )
+}
+
+function shouldFetchOptionally(
+  query: Query<any, any>,
+  prevQuery: Query<any, any>,
+  options: QueryObserverOptions<any, any>,
+  prevOptions: QueryObserverOptions<any, any>
+): boolean {
+  return (
+    (query !== prevQuery ||
+      (options.enabled !== false && prevOptions.enabled === false)) &&
+    isStale(query, options)
+  )
+}
+
+function isStale(
+  query: Query<any, any>,
+  options: QueryObserverOptions<any, any>
+): boolean {
+  return query.isStaleByTime(options.staleTime)
+}
diff --git a/src/core/types.ts b/src/core/types.ts
index 40ecfec39..e0457820f 100644
--- a/src/core/types.ts
+++ b/src/core/types.ts
@@ -179,6 +179,12 @@ export interface QueryObserverOptions<
    * If set, this value will be used as the placeholder data for this particular query observer while the query is still in the `loading` data and no initialData has been provided.
    */
   placeholderData?: TData | PlaceholderDataFunction<TData>
+  /**
+   * If set, the observer will optimistically set the result in fetching state before the query has actually started fetching.
+   * This is to make sure the results are not lagging behind.
+   * Defaults to `true`.
+   */
+  optimisticResults?: boolean
 }
 
 export interface InfiniteQueryObserverOptions<
@@ -217,7 +223,7 @@ export interface ResultOptions {
 }
 
 export interface RefetchOptions extends ResultOptions {
-  cancelRefetch?: boolean;
+  cancelRefetch?: boolean
 }
 
 export interface InvalidateQueryFilters extends QueryFilters {
diff --git a/src/core/utils.ts b/src/core/utils.ts
index 3eb8eab3c..56dc8ca72 100644
--- a/src/core/utils.ts
+++ b/src/core/utils.ts
@@ -7,7 +7,6 @@ import type {
   QueryKey,
   QueryKeyHashFunction,
   QueryOptions,
-  QueryStatus,
 } from './types'
 
 // TYPES
@@ -345,16 +344,6 @@ export function sleep(timeout: number): Promise<void> {
   })
 }
 
-export function getStatusProps<T extends QueryStatus>(status: T) {
-  return {
-    status,
-    isLoading: status === 'loading',
-    isSuccess: status === 'success',
-    isError: status === 'error',
-    isIdle: status === 'idle',
-  }
-}
-
 /**
  * Schedules a microtask.
  * This can be useful to schedule state updates after rendering.
diff --git a/src/devtools/useLocalStorage.ts b/src/devtools/useLocalStorage.ts
index b26a7f45d..a7f46e955 100644
--- a/src/devtools/useLocalStorage.ts
+++ b/src/devtools/useLocalStorage.ts
@@ -11,13 +11,17 @@ const getItem = key => {
 }
 
 export default function useLocalStorage(key, defaultValue) {
-  const [value, setValue] = React.useState(() => {
-    const val = getItem(key)
-    if (typeof val === 'undefined' || val === null) {
-      return typeof defaultValue === 'function' ? defaultValue() : defaultValue
+  const [value, setValue] = React.useState()
+
+  useEffect(() => {
+    const initialValue = getItem(key)
+
+    if (typeof initialValue === 'undefined' || initialValue === null) {
+      setValue(typeof defaultValue === 'function' ? defaultValue() : defaultValue)
+    } else {
+      setValue(initialValue)
     }
-    return val
-  })
+  }, []);
 
   const setter = React.useCallback(
     updater => {
diff --git a/src/react/useBaseQuery.ts b/src/react/useBaseQuery.ts
index b004a3687..220cefbfd 100644
--- a/src/react/useBaseQuery.ts
+++ b/src/react/useBaseQuery.ts
@@ -1,22 +1,25 @@
 import React from 'react'
 
-import { QueryObserverResult } from '../core/types'
 import { notifyManager } from '../core/notifyManager'
 import { QueryObserver } from '../core/queryObserver'
 import { useQueryErrorResetBoundary } from './QueryErrorResetBoundary'
 import { useQueryClient } from './QueryClientProvider'
 import { UseBaseQueryOptions } from './types'
-import { useIsMounted } from './useIsMounted'
 
 export function useBaseQuery<TQueryFnData, TError, TData, TQueryData>(
   options: UseBaseQueryOptions<TQueryFnData, TError, TData, TQueryData>,
   Observer: typeof QueryObserver
 ) {
-  const isMounted = useIsMounted()
+  const mountedRef = React.useRef(false)
+  const [, forceUpdate] = React.useState(0)
+
   const queryClient = useQueryClient()
   const errorResetBoundary = useQueryErrorResetBoundary()
   const defaultedOptions = queryClient.defaultQueryObserverOptions(options)
 
+  // Make sure results are optimistically set in fetching state before subscribing or updating options
+  defaultedOptions.optimisticResults = true
+
   // Include callbacks in batch renders
   if (defaultedOptions.onError) {
     defaultedOptions.onError = notifyManager.batchCalls(
@@ -42,53 +45,79 @@ export function useBaseQuery<TQueryFnData, TError, TData, TQueryData>(
     if (typeof defaultedOptions.staleTime !== 'number') {
       defaultedOptions.staleTime = 1000
     }
+  }
 
+  if (defaultedOptions.suspense || defaultedOptions.useErrorBoundary) {
     // Prevent retrying failed query if the error boundary has not been reset yet
     if (!errorResetBoundary.isReset()) {
       defaultedOptions.retryOnMount = false
     }
   }
 
-  // Create query observer
-  const observerRef = React.useRef<QueryObserver<any, any, any, any>>()
-  const observer =
-    observerRef.current || new Observer(queryClient, defaultedOptions)
-  observerRef.current = observer
+  const obsRef = React.useRef<QueryObserver<any, any>>()
 
-  // Update options
-  if (observer.hasListeners()) {
-    observer.setOptions(defaultedOptions)
+  if (!obsRef.current) {
+    obsRef.current = new Observer(queryClient, defaultedOptions)
   }
 
-  const currentResult = observer.getCurrentResult()
-  const [, setCurrentResult] = React.useState(currentResult)
+  let result = obsRef.current.getOptimisticResult(defaultedOptions)
 
-  // Subscribe to the observer
   React.useEffect(() => {
-    errorResetBoundary.clearReset()
-    return observer.subscribe(
-      notifyManager.batchCalls((result: QueryObserverResult) => {
-        if (isMounted()) {
-          setCurrentResult(result)
+    mountedRef.current = true
+
+    const unsubscribe = obsRef.current!.subscribe(
+      notifyManager.batchCalls(() => {
+        errorResetBoundary.clearReset()
+        if (mountedRef.current) {
+          forceUpdate(x => x + 1)
         }
       })
     )
-  }, [observer, errorResetBoundary, isMounted])
 
-  // Handle suspense
-  if (observer.options.suspense || observer.options.useErrorBoundary) {
-    if (observer.options.suspense && currentResult.isLoading) {
-      errorResetBoundary.clearReset()
-      const unsubscribe = observer.subscribe()
-      throw observer.refetch().finally(unsubscribe)
-    }
+    // Update result to make sure we did not miss any query updates
+    // between creating the observer and subscribing to it.
+    obsRef.current!.updateResult()
 
-    if (currentResult.isError) {
-      throw currentResult.error
+    return () => {
+      mountedRef.current = false
+      unsubscribe()
     }
+  }, [errorResetBoundary])
+
+  React.useEffect(() => {
+    // Do not notify on updates because of changes in the options because
+    // these changes should already be reflected in the optimistic result.
+    obsRef.current!.setOptions(defaultedOptions, { listeners: false })
+  }, [defaultedOptions])
+
+  // Handle suspense
+  if (obsRef.current.options.suspense && result.isLoading) {
+    throw queryClient
+      .fetchQuery(defaultedOptions)
+      .then(data => {
+        defaultedOptions.onSuccess?.(data)
+        defaultedOptions.onSettled?.(data, null)
+      })
+      .catch(error => {
+        errorResetBoundary.clearReset()
+        defaultedOptions.onError?.(error)
+        defaultedOptions.onSettled?.(undefined, error)
+      })
+  }
+
+  // Handle error boundary
+  if (
+    (obsRef.current.options.suspense ||
+      obsRef.current.options.useErrorBoundary) &&
+    result.isError
+  ) {
+    throw result.error
+  }
+
+  // Handle result property usage tracking
+  if (obsRef.current.options.notifyOnChangeProps === 'tracked') {
+    result = obsRef.current.trackResult(result)
   }
 
-  return observer.options.notifyOnChangeProps === 'tracked'
-    ? observer.getTrackedCurrentResult()
-    : currentResult
+  return result
 }
diff --git a/src/react/useIsFetching.ts b/src/react/useIsFetching.ts
index c7c919f14..a1319c53d 100644
--- a/src/react/useIsFetching.ts
+++ b/src/react/useIsFetching.ts
@@ -4,7 +4,6 @@ import { notifyManager } from '../core/notifyManager'
 import { QueryKey } from '../core/types'
 import { parseFilterArgs, QueryFilters } from '../core/utils'
 import { useQueryClient } from './QueryClientProvider'
-import { useIsMounted } from './useIsMounted'
 
 export function useIsFetching(filters?: QueryFilters): number
 export function useIsFetching(
@@ -15,8 +14,10 @@ export function useIsFetching(
   arg1?: QueryKey | QueryFilters,
   arg2?: QueryFilters
 ): number {
-  const isMounted = useIsMounted()
+  const mountedRef = React.useRef(false)
+
   const queryClient = useQueryClient()
+
   const [filters] = parseFilterArgs(arg1, arg2)
   const [isFetching, setIsFetching] = React.useState(
     queryClient.isFetching(filters)
@@ -27,20 +28,25 @@ export function useIsFetching(
   const isFetchingRef = React.useRef(isFetching)
   isFetchingRef.current = isFetching
 
-  React.useEffect(
-    () =>
-      queryClient.getQueryCache().subscribe(
-        notifyManager.batchCalls(() => {
-          if (isMounted()) {
-            const newIsFetching = queryClient.isFetching(filtersRef.current)
-            if (isFetchingRef.current !== newIsFetching) {
-              setIsFetching(newIsFetching)
-            }
+  React.useEffect(() => {
+    mountedRef.current = true
+
+    const unsubscribe = queryClient.getQueryCache().subscribe(
+      notifyManager.batchCalls(() => {
+        if (mountedRef.current) {
+          const newIsFetching = queryClient.isFetching(filtersRef.current)
+          if (isFetchingRef.current !== newIsFetching) {
+            setIsFetching(newIsFetching)
           }
-        })
-      ),
-    [queryClient, isMounted]
-  )
+        }
+      })
+    )
+
+    return () => {
+      mountedRef.current = false
+      unsubscribe()
+    }
+  }, [queryClient])
 
   return isFetching
 }
diff --git a/src/react/useIsMounted.ts b/src/react/useIsMounted.ts
deleted file mode 100644
index f1f620f72..000000000
--- a/src/react/useIsMounted.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import React from 'react'
-
-import { isServer } from '../core/utils'
-
-export function useIsMounted() {
-  const mountedRef = React.useRef(false)
-  const isMounted = React.useCallback(() => mountedRef.current, [])
-
-  React[isServer ? 'useEffect' : 'useLayoutEffect'](() => {
-    mountedRef.current = true
-    return () => {
-      mountedRef.current = false
-    }
-  }, [])
-
-  return isMounted
-}
diff --git a/src/react/useMutation.ts b/src/react/useMutation.ts
index d5fb6374e..a841b27d1 100644
--- a/src/react/useMutation.ts
+++ b/src/react/useMutation.ts
@@ -9,12 +9,7 @@ import {
   UseMutationOptions,
   UseMutationResult,
 } from './types'
-import {
-  MutationFunction,
-  MutationKey,
-  MutationObserverResult,
-} from '../core/types'
-import { useIsMounted } from './useIsMounted'
+import { MutationFunction, MutationKey } from '../core/types'
 
 // HOOK
 
@@ -69,54 +64,45 @@ export function useMutation<
     | UseMutationOptions<TData, TError, TVariables, TContext>,
   arg3?: UseMutationOptions<TData, TError, TVariables, TContext>
 ): UseMutationResult<TData, TError, TVariables, TContext> {
-  const isMounted = useIsMounted()
+  const mountedRef = React.useRef(false)
+  const [, forceUpdate] = React.useState(0)
+
   const options = parseMutationArgs(arg1, arg2, arg3)
   const queryClient = useQueryClient()
 
-  // Create mutation observer
-  const observerRef = React.useRef<
-    MutationObserver<TData, TError, TVariables, TContext>
-  >()
-  const observer =
-    observerRef.current || new MutationObserver(queryClient, options)
-  observerRef.current = observer
+  const obsRef = React.useRef<MutationObserver<any, any, any, any>>()
 
-  // Update options
-  if (observer.hasListeners()) {
-    observer.setOptions(options)
+  if (!obsRef.current) {
+    obsRef.current = new MutationObserver(queryClient, options)
+  } else {
+    obsRef.current.setOptions(options)
   }
 
-  const [currentResult, setCurrentResult] = React.useState(() =>
-    observer.getCurrentResult()
-  )
+  const currentResult = obsRef.current.getCurrentResult()
+
+  React.useEffect(() => {
+    mountedRef.current = true
 
-  // Subscribe to the observer
-  React.useEffect(
-    () =>
-      observer.subscribe(
-        notifyManager.batchCalls(
-          (
-            result: MutationObserverResult<TData, TError, TVariables, TContext>
-          ) => {
-            if (isMounted()) {
-              setCurrentResult(result)
-            }
-          }
-        )
-      ),
-    [observer, isMounted]
-  )
+    const unsubscribe = obsRef.current!.subscribe(
+      notifyManager.batchCalls(() => {
+        if (mountedRef.current) {
+          forceUpdate(x => x + 1)
+        }
+      })
+    )
+    return () => {
+      mountedRef.current = false
+      unsubscribe()
+    }
+  }, [])
 
   const mutate = React.useCallback<
     UseMutateFunction<TData, TError, TVariables, TContext>
-  >(
-    (variables, mutateOptions) => {
-      observer.mutate(variables, mutateOptions).catch(noop)
-    },
-    [observer]
-  )
+  >((variables, mutateOptions) => {
+    obsRef.current!.mutate(variables, mutateOptions).catch(noop)
+  }, [])
 
-  if (currentResult.error && observer.options.useErrorBoundary) {
+  if (currentResult.error && obsRef.current.options.useErrorBoundary) {
     throw currentResult.error
   }
 
diff --git a/src/react/useQueries.ts b/src/react/useQueries.ts
index 1a85857bd..ce548b2d7 100644
--- a/src/react/useQueries.ts
+++ b/src/react/useQueries.ts
@@ -1,43 +1,55 @@
 import React from 'react'
 
-import { QueryObserverResult } from '../core/types'
 import { notifyManager } from '../core/notifyManager'
 import { QueriesObserver } from '../core/queriesObserver'
 import { useQueryClient } from './QueryClientProvider'
 import { UseQueryOptions, UseQueryResult } from './types'
-import { useIsMounted } from './useIsMounted'
 
 export function useQueries(queries: UseQueryOptions[]): UseQueryResult[] {
-  const isMounted = useIsMounted()
+  const mountedRef = React.useRef(false)
+  const [, forceUpdate] = React.useState(0)
+
   const queryClient = useQueryClient()
 
-  // Create queries observer
-  const observerRef = React.useRef<QueriesObserver>()
-  const observer =
-    observerRef.current || new QueriesObserver(queryClient, queries)
-  observerRef.current = observer
+  const defaultedQueries = queries.map(options => {
+    const defaultedOptions = queryClient.defaultQueryObserverOptions(options)
+
+    // Make sure the results are already in fetching state before subscribing or updating options
+    defaultedOptions.optimisticResults = true
+
+    return defaultedOptions
+  })
 
-  // Update queries
-  if (observer.hasListeners()) {
-    observer.setQueries(queries)
+  const obsRef = React.useRef<QueriesObserver>()
+
+  if (!obsRef.current) {
+    obsRef.current = new QueriesObserver(queryClient, defaultedQueries)
   }
 
-  const [currentResult, setCurrentResult] = React.useState(() =>
-    observer.getCurrentResult()
-  )
-
-  // Subscribe to the observer
-  React.useEffect(
-    () =>
-      observer.subscribe(
-        notifyManager.batchCalls((result: QueryObserverResult[]) => {
-          if (isMounted()) {
-            setCurrentResult(result)
-          }
-        })
-      ),
-    [observer, isMounted]
-  )
-
-  return currentResult
+  const result = obsRef.current.getOptimisticResult(defaultedQueries)
+
+  React.useEffect(() => {
+    mountedRef.current = true
+
+    const unsubscribe = obsRef.current!.subscribe(
+      notifyManager.batchCalls(() => {
+        if (mountedRef.current) {
+          forceUpdate(x => x + 1)
+        }
+      })
+    )
+
+    return () => {
+      mountedRef.current = false
+      unsubscribe()
+    }
+  }, [])
+
+  React.useEffect(() => {
+    // Do not notify on updates because of changes in the options because
+    // these changes should already be reflected in the optimistic result.
+    obsRef.current!.setQueries(defaultedQueries, { listeners: false })
+  }, [defaultedQueries])
+
+  return result
 }


Test patch:
diff --git a/docs/src/pages/guides/testing.md b/docs/src/pages/guides/testing.md
index 9b0b5bdcc..a3bb0cfb7 100644
--- a/docs/src/pages/guides/testing.md
+++ b/docs/src/pages/guides/testing.md
@@ -21,8 +21,7 @@ Once installed, a simple test can be written. Given the following custom hook:
 
 ```
 export function useCustomHook() {
-  const { data } = useQuery('customHook', () => 'Hello');
-  return data;
+  return useQuery('customHook', () => 'Hello');
 }
 ```
 
@@ -36,9 +35,11 @@ const wrapper = ({ children }) => (
   </QueryClientProvider>
 );
 
-const { result } = renderHook(() => useCustomHook(), { wrapper });
+const { result, waitFor } = renderHook(() => useCustomHook(), { wrapper });
 
-expect(result.current).toEqual('Hello');
+await waitFor(() => result.current.isSuccess);
+
+expect(result.current.data).toEqual("Hello");
 ```
 
 Note that we provide a custom wrapper that builds the `QueryClient` and `QueryClientProvider`. This helps to ensure that our test is completely isolated from any other tests.
diff --git a/src/core/tests/queriesObserver.test.tsx b/src/core/tests/queriesObserver.test.tsx
index b960ed0d3..e424987ff 100644
--- a/src/core/tests/queriesObserver.test.tsx
+++ b/src/core/tests/queriesObserver.test.tsx
@@ -49,12 +49,30 @@ describe('queriesObserver', () => {
     queryClient.setQueryData(key2, 3)
     await sleep(1)
     unsubscribe()
-    expect(results.length).toBe(4)
-    expect(results).toMatchObject([
-      [{ data: undefined }, { data: undefined }],
-      [{ data: 1 }, { data: undefined }],
-      [{ data: 1 }, { data: 2 }],
-      [{ data: 1 }, { data: 3 }],
+    expect(results.length).toBe(6)
+    expect(results[0]).toMatchObject([
+      { status: 'idle', data: undefined },
+      { status: 'idle', data: undefined },
+    ])
+    expect(results[1]).toMatchObject([
+      { status: 'loading', data: undefined },
+      { status: 'idle', data: undefined },
+    ])
+    expect(results[2]).toMatchObject([
+      { status: 'loading', data: undefined },
+      { status: 'loading', data: undefined },
+    ])
+    expect(results[3]).toMatchObject([
+      { status: 'success', data: 1 },
+      { status: 'loading', data: undefined },
+    ])
+    expect(results[4]).toMatchObject([
+      { status: 'success', data: 1 },
+      { status: 'success', data: 2 },
+    ])
+    expect(results[5]).toMatchObject([
+      { status: 'success', data: 1 },
+      { status: 'success', data: 3 },
     ])
   })
 
@@ -81,13 +99,28 @@ describe('queriesObserver', () => {
     unsubscribe()
     expect(queryCache.find(key1, { active: true })).toBeUndefined()
     expect(queryCache.find(key2, { active: true })).toBeUndefined()
-    expect(results.length).toBe(4)
-    expect(results).toMatchObject([
-      [{ data: undefined }, { data: undefined }],
-      [{ data: 1 }, { data: undefined }],
-      [{ data: 1 }, { data: 2 }],
-      [{ data: 2 }],
+    expect(results.length).toBe(6)
+    expect(results[0]).toMatchObject([
+      { status: 'idle', data: undefined },
+      { status: 'idle', data: undefined },
+    ])
+    expect(results[1]).toMatchObject([
+      { status: 'loading', data: undefined },
+      { status: 'idle', data: undefined },
+    ])
+    expect(results[2]).toMatchObject([
+      { status: 'loading', data: undefined },
+      { status: 'loading', data: undefined },
+    ])
+    expect(results[3]).toMatchObject([
+      { status: 'success', data: 1 },
+      { status: 'loading', data: undefined },
+    ])
+    expect(results[4]).toMatchObject([
+      { status: 'success', data: 1 },
+      { status: 'success', data: 2 },
     ])
+    expect(results[5]).toMatchObject([{ status: 'success', data: 2 }])
   })
 
   test('should update when a query changed position', async () => {
@@ -111,12 +144,30 @@ describe('queriesObserver', () => {
     ])
     await sleep(1)
     unsubscribe()
-    expect(results.length).toBe(4)
-    expect(results).toMatchObject([
-      [{ data: undefined }, { data: undefined }],
-      [{ data: 1 }, { data: undefined }],
-      [{ data: 1 }, { data: 2 }],
-      [{ data: 2 }, { data: 1 }],
+    expect(results.length).toBe(6)
+    expect(results[0]).toMatchObject([
+      { status: 'idle', data: undefined },
+      { status: 'idle', data: undefined },
+    ])
+    expect(results[1]).toMatchObject([
+      { status: 'loading', data: undefined },
+      { status: 'idle', data: undefined },
+    ])
+    expect(results[2]).toMatchObject([
+      { status: 'loading', data: undefined },
+      { status: 'loading', data: undefined },
+    ])
+    expect(results[3]).toMatchObject([
+      { status: 'success', data: 1 },
+      { status: 'loading', data: undefined },
+    ])
+    expect(results[4]).toMatchObject([
+      { status: 'success', data: 1 },
+      { status: 'success', data: 2 },
+    ])
+    expect(results[5]).toMatchObject([
+      { status: 'success', data: 2 },
+      { status: 'success', data: 1 },
     ])
   })
 
@@ -141,11 +192,26 @@ describe('queriesObserver', () => {
     ])
     await sleep(1)
     unsubscribe()
-    expect(results.length).toBe(3)
-    expect(results).toMatchObject([
-      [{ data: undefined }, { data: undefined }],
-      [{ data: 1 }, { data: undefined }],
-      [{ data: 1 }, { data: 2 }],
+    expect(results.length).toBe(5)
+    expect(results[0]).toMatchObject([
+      { status: 'idle', data: undefined },
+      { status: 'idle', data: undefined },
+    ])
+    expect(results[1]).toMatchObject([
+      { status: 'loading', data: undefined },
+      { status: 'idle', data: undefined },
+    ])
+    expect(results[2]).toMatchObject([
+      { status: 'loading', data: undefined },
+      { status: 'loading', data: undefined },
+    ])
+    expect(results[3]).toMatchObject([
+      { status: 'success', data: 1 },
+      { status: 'loading', data: undefined },
+    ])
+    expect(results[4]).toMatchObject([
+      { status: 'success', data: 1 },
+      { status: 'success', data: 2 },
     ])
   })
 
diff --git a/src/core/tests/queryObserver.test.tsx b/src/core/tests/queryObserver.test.tsx
index 91dcbc673..35939d749 100644
--- a/src/core/tests/queryObserver.test.tsx
+++ b/src/core/tests/queryObserver.test.tsx
@@ -43,28 +43,11 @@ describe('queryObserver', () => {
     observer.setOptions({ queryKey: key2, queryFn: () => 2 })
     await sleep(1)
     unsubscribe()
-    expect(results.length).toBe(3)
-    expect(results[0]).toMatchObject({ data: 1, status: 'success' })
-    expect(results[1]).toMatchObject({ data: undefined, status: 'loading' })
-    expect(results[2]).toMatchObject({ data: 2, status: 'success' })
-  })
-
-  test('should notify when the query has updated before subscribing', async () => {
-    const key = queryKey()
-    const results: QueryObserverResult[] = []
-    const observer = new QueryObserver(queryClient, {
-      queryKey: key,
-      queryFn: () => 1,
-      staleTime: Infinity,
-    })
-    queryClient.setQueryData(key, 2)
-    const unsubscribe = observer.subscribe(result => {
-      results.push(result)
-    })
-    await sleep(1)
-    unsubscribe()
-    expect(results.length).toBe(1)
-    expect(results[0]).toMatchObject({ data: 2, status: 'success' })
+    expect(results.length).toBe(4)
+    expect(results[0]).toMatchObject({ data: undefined, status: 'loading' })
+    expect(results[1]).toMatchObject({ data: 1, status: 'success' })
+    expect(results[2]).toMatchObject({ data: undefined, status: 'loading' })
+    expect(results[3]).toMatchObject({ data: 2, status: 'success' })
   })
 
   test('should be able to fetch with a selector', async () => {
@@ -161,23 +144,28 @@ describe('queryObserver', () => {
     await observer.refetch()
     unsubscribe()
     expect(count).toBe(2)
-    expect(results.length).toBe(4)
+    expect(results.length).toBe(5)
     expect(results[0]).toMatchObject({
+      status: 'loading',
+      isFetching: true,
+      data: undefined,
+    })
+    expect(results[1]).toMatchObject({
       status: 'success',
       isFetching: false,
       data: { myCount: 1 },
     })
-    expect(results[1]).toMatchObject({
+    expect(results[2]).toMatchObject({
       status: 'success',
       isFetching: false,
       data: { myCount: 99 },
     })
-    expect(results[2]).toMatchObject({
+    expect(results[3]).toMatchObject({
       status: 'success',
       isFetching: true,
       data: { myCount: 99 },
     })
-    expect(results[3]).toMatchObject({
+    expect(results[4]).toMatchObject({
       status: 'success',
       isFetching: false,
       data: { myCount: 99 },
@@ -211,18 +199,23 @@ describe('queryObserver', () => {
     await observer.refetch()
     unsubscribe()
     expect(count).toBe(1)
-    expect(results.length).toBe(3)
+    expect(results.length).toBe(4)
     expect(results[0]).toMatchObject({
+      status: 'loading',
+      isFetching: true,
+      data: undefined,
+    })
+    expect(results[1]).toMatchObject({
       status: 'success',
       isFetching: false,
       data: { myCount: 1 },
     })
-    expect(results[1]).toMatchObject({
+    expect(results[2]).toMatchObject({
       status: 'success',
       isFetching: true,
       data: { myCount: 1 },
     })
-    expect(results[2]).toMatchObject({
+    expect(results[3]).toMatchObject({
       status: 'success',
       isFetching: false,
       data: { myCount: 1 },
@@ -432,8 +425,8 @@ describe('queryObserver', () => {
     })
 
     expect(observer.getCurrentResult()).toMatchObject({
-      status: 'success',
-      data: 'placeholder',
+      status: 'idle',
+      data: undefined,
     })
 
     const results: QueryObserverResult<unknown>[] = []
@@ -443,7 +436,10 @@ describe('queryObserver', () => {
     })
 
     await sleep(10)
-    expect(results[0].data).toBe('data')
     unsubscribe()
+
+    expect(results.length).toBe(2)
+    expect(results[0]).toMatchObject({ status: 'success', data: 'placeholder' })
+    expect(results[1]).toMatchObject({ status: 'success', data: 'data' })
   })
 })
diff --git a/src/hydration/tests/hydration.test.tsx b/src/hydration/tests/hydration.test.tsx
index 3527e9ecb..d8564dad6 100644
--- a/src/hydration/tests/hydration.test.tsx
+++ b/src/hydration/tests/hydration.test.tsx
@@ -128,7 +128,7 @@ describe('dehydration and rehydration', () => {
     await hydrationClient.prefetchQuery(
       ['string', { key: ['string'], key2: 0 }],
       fetchDataAfterHydration,
-      { staleTime: 10 }
+      { staleTime: 100 }
     )
     expect(fetchDataAfterHydration).toHaveBeenCalledTimes(0)
 
diff --git a/src/react/tests/QueryResetErrorBoundary.test.tsx b/src/react/tests/QueryResetErrorBoundary.test.tsx
index af0c368ed..59c2094e3 100644
--- a/src/react/tests/QueryResetErrorBoundary.test.tsx
+++ b/src/react/tests/QueryResetErrorBoundary.test.tsx
@@ -73,6 +73,132 @@ describe('QueryErrorResetBoundary', () => {
     consoleMock.mockRestore()
   })
 
+  it('should not retry fetch if the reset error boundary has not been reset', async () => {
+    const key = queryKey()
+
+    let succeed = false
+    const consoleMock = mockConsoleError()
+
+    function Page() {
+      const { data } = useQuery(
+        key,
+        async () => {
+          await sleep(10)
+          if (!succeed) {
+            throw new Error('Error')
+          } else {
+            return 'data'
+          }
+        },
+        {
+          retry: false,
+          useErrorBoundary: true,
+        }
+      )
+      return <div>{data}</div>
+    }
+
+    const rendered = renderWithClient(
+      queryClient,
+      <QueryErrorResetBoundary>
+        {() => (
+          <ErrorBoundary
+            fallbackRender={({ resetErrorBoundary }) => (
+              <div>
+                <div>error boundary</div>
+                <button
+                  onClick={() => {
+                    resetErrorBoundary()
+                  }}
+                >
+                  retry
+                </button>
+              </div>
+            )}
+          >
+            <Page />
+          </ErrorBoundary>
+        )}
+      </QueryErrorResetBoundary>
+    )
+
+    await waitFor(() => rendered.getByText('error boundary'))
+    await waitFor(() => rendered.getByText('retry'))
+    succeed = true
+    fireEvent.click(rendered.getByText('retry'))
+    await waitFor(() => rendered.getByText('error boundary'))
+
+    consoleMock.mockRestore()
+  })
+
+  it('should not retry fetch if the reset error boundary has not been reset after a previous reset', async () => {
+    const key = queryKey()
+
+    let succeed = false
+    let shouldReset = true
+    const consoleMock = mockConsoleError()
+
+    function Page() {
+      const { data } = useQuery(
+        key,
+        async () => {
+          await sleep(10)
+          if (!succeed) {
+            throw new Error('Error')
+          } else {
+            return 'data'
+          }
+        },
+        {
+          retry: false,
+          useErrorBoundary: true,
+        }
+      )
+      return <div>{data}</div>
+    }
+
+    const rendered = renderWithClient(
+      queryClient,
+      <QueryErrorResetBoundary>
+        {({ reset }) => (
+          <ErrorBoundary
+            onReset={() => {
+              if (shouldReset) {
+                reset()
+              }
+            }}
+            fallbackRender={({ resetErrorBoundary }) => (
+              <div>
+                <div>error boundary</div>
+                <button
+                  onClick={() => {
+                    resetErrorBoundary()
+                  }}
+                >
+                  retry
+                </button>
+              </div>
+            )}
+          >
+            <Page />
+          </ErrorBoundary>
+        )}
+      </QueryErrorResetBoundary>
+    )
+
+    await waitFor(() => rendered.getByText('error boundary'))
+    await waitFor(() => rendered.getByText('retry'))
+    shouldReset = true
+    fireEvent.click(rendered.getByText('retry'))
+    await waitFor(() => rendered.getByText('error boundary'))
+    succeed = true
+    shouldReset = false
+    fireEvent.click(rendered.getByText('retry'))
+    await waitFor(() => rendered.getByText('error boundary'))
+
+    consoleMock.mockRestore()
+  })
+
   it('should throw again on error after the reset error boundary has been reset', async () => {
     const key = queryKey()
     const consoleMock = mockConsoleError()
diff --git a/src/react/tests/suspense.test.tsx b/src/react/tests/suspense.test.tsx
index da6638e94..a8534bc88 100644
--- a/src/react/tests/suspense.test.tsx
+++ b/src/react/tests/suspense.test.tsx
@@ -59,7 +59,7 @@ describe("useQuery's in Suspense mode", () => {
 
     await sleep(20)
 
-    expect(renders).toBe(5)
+    expect(renders).toBe(4)
     expect(states.length).toBe(2)
     expect(states[0]).toMatchObject({ data: 1, status: 'success' })
     expect(states[1]).toMatchObject({ data: 2, status: 'success' })
diff --git a/src/react/tests/useIsFetching.test.tsx b/src/react/tests/useIsFetching.test.tsx
index e1b523313..085242cbf 100644
--- a/src/react/tests/useIsFetching.test.tsx
+++ b/src/react/tests/useIsFetching.test.tsx
@@ -87,7 +87,7 @@ describe('useIsFetching', () => {
       React.useEffect(() => {
         setActTimeout(() => {
           setRenderSecond(true)
-        }, 10)
+        }, 50)
       }, [])
 
       return (
diff --git a/src/react/tests/useQueries.test.tsx b/src/react/tests/useQueries.test.tsx
index 2a0e6f825..c66470a85 100644
--- a/src/react/tests/useQueries.test.tsx
+++ b/src/react/tests/useQueries.test.tsx
@@ -14,8 +14,20 @@ describe('useQueries', () => {
 
     function Page() {
       const result = useQueries([
-        { queryKey: key1, queryFn: () => 1 },
-        { queryKey: key2, queryFn: () => 2 },
+        {
+          queryKey: key1,
+          queryFn: async () => {
+            await sleep(5)
+            return 1
+          },
+        },
+        {
+          queryKey: key2,
+          queryFn: async () => {
+            await sleep(10)
+            return 2
+          },
+        },
       ])
       results.push(result)
       return null
@@ -23,7 +35,7 @@ describe('useQueries', () => {
 
     renderWithClient(queryClient, <Page />)
 
-    await sleep(10)
+    await sleep(30)
 
     expect(results.length).toBe(3)
     expect(results[0]).toMatchObject([{ data: undefined }, { data: undefined }])
diff --git a/src/react/tests/useQuery.test.tsx b/src/react/tests/useQuery.test.tsx
index 9dac880bb..046026342 100644
--- a/src/react/tests/useQuery.test.tsx
+++ b/src/react/tests/useQuery.test.tsx
@@ -863,48 +863,6 @@ describe('useQuery', () => {
     expect(states[0]).toMatchObject({ data: undefined })
   })
 
-  it('should return the referentially same object if nothing changes between fetches', async () => {
-    const key = queryKey()
-    let renderCount = 0
-    const states: UseQueryResult<string>[] = []
-
-    function Page() {
-      const state = useQuery(key, () => 'test', {
-        notifyOnChangeProps: 'tracked',
-      })
-
-      states.push(state)
-
-      const { data } = state
-
-      React.useEffect(() => {
-        renderCount++
-      }, [state])
-
-      return (
-        <div>
-          <h1>{data ?? null}</h1>
-        </div>
-      )
-    }
-
-    const rendered = renderWithClient(queryClient, <Page />)
-
-    await waitFor(() => rendered.getByText('test'))
-    expect(renderCount).toBe(2)
-    expect(states.length).toBe(2)
-    expect(states[0]).toMatchObject({ data: undefined })
-    expect(states[1]).toMatchObject({ data: 'test' })
-
-    act(() => rendered.rerender(<Page />))
-    await waitFor(() => rendered.getByText('test'))
-    expect(renderCount).toBe(2)
-    expect(states.length).toBe(3)
-    expect(states[0]).toMatchObject({ data: undefined })
-    expect(states[1]).toMatchObject({ data: 'test' })
-    expect(states[2]).toMatchObject({ data: 'test' })
-  })
-
   it('should always re-render if we are tracking props but not using any', async () => {
     const key = queryKey()
     let renderCount = 0
@@ -1540,7 +1498,9 @@ describe('useQuery', () => {
 
     renderWithClient(queryClient, <Page />)
 
-    await waitFor(() => expect(states.length).toBe(7))
+    await sleep(100)
+
+    expect(states.length).toBe(6)
 
     // Disabled query
     expect(states[0]).toMatchObject({
@@ -1566,26 +1526,19 @@ describe('useQuery', () => {
     // Set state
     expect(states[3]).toMatchObject({
       data: 0,
-      isFetching: false,
-      isSuccess: true,
-      isPreviousData: true,
-    })
-    // Hook state update
-    expect(states[4]).toMatchObject({
-      data: 0,
-      isFetching: false,
+      isFetching: true,
       isSuccess: true,
       isPreviousData: true,
     })
     // Fetching new query
-    expect(states[5]).toMatchObject({
+    expect(states[4]).toMatchObject({
       data: 0,
       isFetching: true,
       isSuccess: true,
       isPreviousData: true,
     })
     // Fetched new query
-    expect(states[6]).toMatchObject({
+    expect(states[5]).toMatchObject({
       data: 1,
       isFetching: false,
       isSuccess: true,
@@ -1636,7 +1589,7 @@ describe('useQuery', () => {
 
     await sleep(100)
 
-    expect(states.length).toBe(6)
+    expect(states.length).toBe(5)
 
     // Disabled query
     expect(states[0]).toMatchObject({
@@ -1648,33 +1601,26 @@ describe('useQuery', () => {
     // Set state
     expect(states[1]).toMatchObject({
       data: 10,
-      isFetching: false,
+      isFetching: true,
       isSuccess: true,
       isPreviousData: true,
     })
     // Set state
     expect(states[2]).toMatchObject({
       data: 10,
-      isFetching: false,
-      isSuccess: true,
-      isPreviousData: true,
-    })
-    // Hook state update
-    expect(states[3]).toMatchObject({
-      data: 10,
-      isFetching: false,
+      isFetching: true,
       isSuccess: true,
       isPreviousData: true,
     })
     // Refetch
-    expect(states[4]).toMatchObject({
+    expect(states[3]).toMatchObject({
       data: 10,
       isFetching: true,
       isSuccess: true,
       isPreviousData: true,
     })
     // Refetch done
-    expect(states[5]).toMatchObject({
+    expect(states[4]).toMatchObject({
       data: 12,
       isFetching: false,
       isSuccess: true,
@@ -2004,6 +1950,130 @@ describe('useQuery', () => {
     expect(states[1]).toMatchObject({ status: 'success' })
   })
 
+  it('should render correct states even in case of concurrent renders with different properties', async () => {
+    const key = queryKey()
+    const states: UseQueryResult<number>[] = []
+    let concurrent = false
+    const originalUseEffect = React.useEffect
+    const dummyUseEffect = (...args: any[]) => {
+      originalUseEffect(() => {
+        return
+      }, args[1])
+    }
+
+    function Page() {
+      const [count, setCount] = React.useState(0)
+
+      if (concurrent) {
+        React.useEffect = dummyUseEffect
+      }
+
+      const state = useQuery(
+        [key, count],
+        async () => {
+          await sleep(5)
+          return count
+        },
+        { staleTime: Infinity, keepPreviousData: true }
+      )
+
+      if (concurrent) {
+        React.useEffect = originalUseEffect
+      }
+
+      states.push(state)
+
+      React.useEffect(() => {
+        setActTimeout(() => {
+          setCount(1)
+        }, 20)
+
+        // Try to simulate concurrent render which does not trigger effects
+        setActTimeout(() => {
+          concurrent = true
+          setCount(0)
+        }, 40)
+
+        setActTimeout(() => {
+          concurrent = false
+          setCount(2)
+        }, 60)
+      }, [])
+
+      return null
+    }
+
+    renderWithClient(queryClient, <Page />)
+
+    await sleep(100)
+
+    expect(states.length).toBe(9)
+
+    // Load query 0
+    expect(states[0]).toMatchObject({
+      status: 'loading',
+      data: undefined,
+      isFetching: true,
+      isPreviousData: false,
+    })
+    // Fetch done
+    expect(states[1]).toMatchObject({
+      status: 'success',
+      data: 0,
+      isFetching: false,
+      isPreviousData: false,
+    })
+    // Set state to query 1
+    expect(states[2]).toMatchObject({
+      status: 'success',
+      data: 0,
+      isFetching: true,
+      isPreviousData: true,
+    })
+    // Fetch start
+    expect(states[3]).toMatchObject({
+      status: 'success',
+      data: 0,
+      isFetching: true,
+      isPreviousData: true,
+    })
+    // Fetch done
+    expect(states[4]).toMatchObject({
+      status: 'success',
+      data: 1,
+      isFetching: false,
+      isPreviousData: false,
+    })
+    // Concurrent render for query 0
+    expect(states[5]).toMatchObject({
+      status: 'success',
+      data: 0,
+      isFetching: false,
+      isPreviousData: false,
+    })
+    // Set state to query 2 (should have query 1 has previous data)
+    expect(states[6]).toMatchObject({
+      status: 'success',
+      data: 1,
+      isFetching: true,
+      isPreviousData: true,
+    })
+    // Fetch start
+    expect(states[7]).toMatchObject({
+      status: 'success',
+      data: 1,
+      isFetching: true,
+      isPreviousData: true,
+    })
+    // Fetch done
+    expect(states[8]).toMatchObject({
+      status: 'success',
+      data: 2,
+      isFetching: false,
+      isPreviousData: false,
+    })
+  })
+
   it('should batch re-renders', async () => {
     const key = queryKey()
 
@@ -2569,13 +2639,11 @@ describe('useQuery', () => {
 
     await sleep(100)
 
-    expect(states.length).toBe(3)
+    expect(states.length).toBe(2)
     // Initial
     expect(states[0]).toMatchObject({ data: { count: 0 } })
     // Set state
     expect(states[1]).toMatchObject({ data: { count: 1 } })
-    // Hook state update
-    expect(states[2]).toMatchObject({ data: { count: 1 } })
   })
 
   it('should retry specified number of times', async () => {
@@ -3506,4 +3574,29 @@ describe('useQuery', () => {
       isStale: true,
     })
   })
+
+  it('should only call the query hash function once each render', async () => {
+    const key = queryKey()
+
+    let hashes = 0
+    let renders = 0
+
+    function queryKeyHashFn(x: any) {
+      hashes++
+      return JSON.stringify(x)
+    }
+
+    function Page() {
+      renders++
+      useQuery(key, () => 'test', { queryKeyHashFn })
+      return null
+    }
+
+    renderWithClient(queryClient, <Page />)
+
+    await sleep(10)
+
+    expect(renders).toBe(2)
+    expect(hashes).toBe(2)
+  })
 })