Repository: tannerlinsley/react-query
Pull request #1781 (2021-02-11 08:28:37)

fix: queries should not end up in an error state when a component unmounts
Follow up on: https://github.com/tannerlinsley/react-query/issues/1412

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/cx3u426rm](https://vercel.com/tannerlinsley/react-query/cx3u426rm)  
✅ Preview: [https://react-query-git-feature-no-cancel-error-on-unmount.tannerlinsley.vercel.app](https://react-query-git-feature-no-cancel-error-on-unmount.tannerlinsley.vercel.app)


dantman: I'm having a persistent bug in an application I'm currently working on due to this bug. I've tested this PR in my codebase and it fixes the bug. Would love to see it released.

Patch:
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/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/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..353b0b89e 100644
--- a/src/core/query.ts
+++ b/src/core/query.ts
@@ -129,6 +129,7 @@ export class Query<
   queryHash: string
   options!: QueryOptions<TQueryFnData, TError, TData>
   initialState: QueryState<TData, TError>
+  revertState?: QueryState<TData, TError>
   state: QueryState<TData, TError>
   cacheTime!: number
 
@@ -260,7 +261,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 +272,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()
@@ -301,7 +302,7 @@ export class Query<
         // we'll let the query continue so the result can be cached
         if (this.retryer) {
           if (this.retryer.isTransportCancelable) {
-            this.retryer.cancel()
+            this.retryer.cancel({ revert: true })
           } else {
             this.retryer.cancelRetry()
           }
@@ -328,7 +329,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 +337,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) {
@@ -377,6 +379,9 @@ export class Query<
       this.options.behavior?.onFetch(context)
     }
 
+    // Store state in case the current fetch needs to be reverted
+    this.revertState = this.state
+
     // Set to fetching state if not already in it
     if (
       !this.state.isFetching ||
@@ -529,24 +534,8 @@ export class Query<
       case 'error':
         const error = action.error as unknown
 
-        if (isCancelledError(error) && error.revert) {
-          let previousStatus: QueryStatus
-
-          if (!state.dataUpdatedAt && !state.errorUpdatedAt) {
-            previousStatus = 'idle'
-          } else if (state.dataUpdatedAt > state.errorUpdatedAt) {
-            previousStatus = 'success'
-          } else {
-            previousStatus = 'error'
-          }
-
-          return {
-            ...state,
-            fetchFailureCount: 0,
-            isFetching: false,
-            isPaused: false,
-            status: previousStatus,
-          }
+        if (isCancelledError(error) && error.revert && this.revertState) {
+          return { ...this.revertState }
         }
 
         return {
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/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/query.test.tsx b/src/core/tests/query.test.tsx
index baa91aa6c..6b118925a 100644
--- a/src/core/tests/query.test.tsx
+++ b/src/core/tests/query.test.tsx
@@ -247,8 +247,7 @@ describe('query', () => {
     expect(cancel).toHaveBeenCalled()
     expect(query.state).toMatchObject({
       data: undefined,
-      status: 'error',
-      errorUpdateCount: 1,
+      status: 'idle',
     })
   })
 
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/useInfiniteQuery.test.tsx b/src/react/tests/useInfiniteQuery.test.tsx
index e22967bd7..51e8a5579 100644
--- a/src/react/tests/useInfiniteQuery.test.tsx
+++ b/src/react/tests/useInfiniteQuery.test.tsx
@@ -15,7 +15,6 @@ import {
   QueryClient,
   QueryCache,
 } from '../..'
-import { CancelledError } from '../../core'
 
 interface Result {
   items: number[]
@@ -726,6 +725,8 @@ describe('useInfiniteQuery', () => {
     const states: UseInfiniteQueryResult<number>[] = []
     let fetches = 0
 
+    const initialData = { pages: [1, 2, 3, 4], pageParams: [1, 2, 3, 4] }
+
     function List() {
       const state = useInfiniteQuery(
         key,
@@ -735,7 +736,7 @@ describe('useInfiniteQuery', () => {
           return Number(pageParam)
         },
         {
-          initialData: { pages: [1, 2, 3, 4], pageParams: [1, 2, 3, 4] },
+          initialData,
           getNextPageParam: lastPage => lastPage + 1,
         }
       )
@@ -764,8 +765,9 @@ describe('useInfiniteQuery', () => {
     expect(states.length).toBe(1)
     expect(fetches).toBe(2)
     expect(queryClient.getQueryState(key)).toMatchObject({
-      status: 'error',
-      error: expect.any(CancelledError),
+      data: initialData,
+      status: 'success',
+      error: null,
     })
   })
 
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..c7826564f 100644
--- a/src/react/tests/useQuery.test.tsx
+++ b/src/react/tests/useQuery.test.tsx
@@ -13,7 +13,6 @@ import {
 } from './utils'
 import {
   useQuery,
-  CancelledError,
   QueryClient,
   UseQueryResult,
   QueryCache,
@@ -863,48 +862,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 +1497,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 +1525,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 +1588,7 @@ describe('useQuery', () => {
 
     await sleep(100)
 
-    expect(states.length).toBe(6)
+    expect(states.length).toBe(5)
 
     // Disabled query
     expect(states[0]).toMatchObject({
@@ -1648,33 +1600,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 +1949,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 +2638,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 () => {
@@ -3353,7 +3420,7 @@ describe('useQuery', () => {
     renderWithClient(queryClient, <Page />)
 
     await sleep(100)
-    expect(states.length).toBe(5)
+    expect(states.length).toBe(4)
     // Load query 1
     expect(states[0]).toMatchObject({
       status: 'loading',
@@ -3367,15 +3434,10 @@ describe('useQuery', () => {
     // Load query 1
     expect(states[2]).toMatchObject({
       status: 'loading',
-      error: expect.any(CancelledError),
-    })
-    // State update
-    expect(states[3]).toMatchObject({
-      status: 'loading',
-      error: expect.any(CancelledError),
+      error: null,
     })
     // Loaded query 1
-    expect(states[4]).toMatchObject({
+    expect(states[3]).toMatchObject({
       status: 'success',
       error: null,
     })
@@ -3506,4 +3568,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)
+  })
 })