Repository: tannerlinsley/react-query
Pull request #754 (2020-07-15 11:36:32)

fix: reduce the number of renders
Hi there! This PR includes the following changes:

- Make sure the hook return values match the public APIs.
- Make sure the hooks only re-render when something has changed.
- Make sure the hook return values are stable / memoized.
- Batch the isStale and succes update together when possible.
- Batch the failureCount and error update together when possible.

With these changes, the number of renders for successful and unsuccessful queries can be reduced from 4 to 2.

PS: While working on the tests, I noticed the `canFetchMore` and `isFetchingMore` properties are sometimes set to `undefined`. I guess this is not correct? For now I matched the assertions with the current behaviour.

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 on the icon next to each commit.
	
tannerlinsley: Wow, this is a lot to take in all at once. I'll try and get to this asap.
boschni: Happy to help! I have adjusted the PR with these changes:

- Removed the API normalisation in `useBaseQuery`. It would indeed be better to do this in a major version.
- Removed the state equality checks in `useBaseQuery` and moved them into `query` to make sure the state is only updated and dispatched when something has changed.

With these changes the number of renders is still reduced to 2 and I think this is actually a simpler and more performant solution :) But if you have any doubts about the implementation then I don’t mind splitting it up.

PS: I removed the `queryInfoRef` because with this implementation the reference would not get updated when a value inside the query info object changes. The idea with memoization is that the reference remains the same unless something has changed. This is more of a nice to have anyway because in most cases consumers will destructure the query info object instead of doing checks on the object itself.
tannerlinsley: It’s looking better! So do you really think we need the shallow equal? If we are using a reducer we should be able to do an identity check instead, right?
boschni: Currently the state always changes when an action is dispatched, regardless of whether some value has actually changed or not.

We could add manual checks in the reducer (or before dispatching actions) to prevent this.

For example:
```
case actionFetch:
  const status = typeof state.data !== 'undefined' ? statusSuccess : statusLoading
  const isFetching = true
  const failureCount = 0

  if (state.status === status && state.isFetching === isFetching && state.failureCount === failureCount) {
    return state
  }

  return {  ...state, status, isFetching, failureCount }
```
But this would need to be done in multiple places and is a bit prone to mistakes.

Doing a shallow check after the state update is probably the simplest and most bullet-proof solution.
tannerlinsley: Alright, thats fair. How about we do this instead and just get rid of the shallow equal?
```js
// Do nothing if the state did not change
    if (deepIncludes(query.state, newState)) {
      return
    }
```

It passes your tests! ;)
boschni: The `deepIncludes` function would be a bit slower because it does a deep comparison (including response data). Also, because it checks if `b` is a subset of `a` it does not really check if the objects are equal. One way to solve this would be to run the check two times like `deepIncludes(query.state, newState) && deepIncludes(newState, query.state)` but that would slow it down even more. Think the `shallowEqual` function is more suited for the job :)
tannerlinsley: Okay. The primary motivation for that suggestion was just bundle size. So I’m good if that’s the only way to do it right.
On Jul 16, 2020, 10:13 AM -0600, Niek Bosch <notifications@github.com>, wrote:
> The deepIncludes function would be a bit slower because it does a deep comparison (including response data). Also, because it checks if b is a subset of a it does not really check if the objects are equal. One way to solve this would be to run the check two times like deepIncludes(query.state, newState) && deepIncludes(newState, query.state) but that would slow it down even more. Think the shallowEqual function is more suited for the job :)
> —
> You are receiving this because you commented.
> Reply to this email directly, view it on GitHub, or unsubscribe.

tannerlinsley: Can you pull in the latest from master?
boschni: Updated 👍 
boschni: Updated the branch again and removed the `getBaseQueryInfo` part

Patch:
diff --git a/docs/src/pages/docs/api.md b/docs/src/pages/docs/api.md
index 668b41b83..e72a23783 100644
--- a/docs/src/pages/docs/api.md
+++ b/docs/src/pages/docs/api.md
@@ -222,7 +222,7 @@ The returned properties for `usePaginatedQuery` are identical to the [`useQuery`
   - If using `paginated` mode, this will be `true` when fetching more results using the `fetchMore` function.
 - `fetchMore: Function(fetchMoreVariableOverride) => Promise`
   - This function allows you to fetch the next "page" of results.
-  - `fetchMoreVariableOverride` allows you to optionally override the fetch more variable returned from your `getCanFetchMore` option to your query function to retrieve the next page of results.
+  - `fetchMoreVariableOverride` allows you to optionally override the fetch more variable returned from your `getFetchMore` option to your query function to retrieve the next page of results.
 - `canFetchMore: Boolean`
   - If using `paginated` mode, this will be `true` if there is more data to be fetched (known via the required `getFetchMore` option function).
 
diff --git a/src/core/query.js b/src/core/query.js
index 74363f838..c7121b1ab 100644
--- a/src/core/query.js
+++ b/src/core/query.js
@@ -10,6 +10,7 @@ import {
   statusIdle,
   Console,
   getStatusBools,
+  shallowEqual,
 } from './utils'
 import { makeQueryInstance } from './queryInstance'
 
@@ -63,9 +64,14 @@ export function makeQuery({
   }
 
   query.dispatch = action => {
-    query.state = queryReducer(query.state, action)
-    query.instances.forEach(d => d.onStateUpdate(query.state))
-    notifyGlobalListeners(query)
+    const newState = queryReducer(query.state, action)
+
+    // Only update state if something has changed
+    if (!shallowEqual(query.state, newState)) {
+      query.state = newState
+      query.instances.forEach(d => d.onStateUpdate(query.state))
+      notifyGlobalListeners(query)
+    }
   }
 
   query.scheduleStaleTimeout = () => {
@@ -143,11 +149,19 @@ export function makeQuery({
   query.setState = updater => query.dispatch({ type: actionSetState, updater })
 
   query.setData = updater => {
+    const isStale = query.config.staleTime === 0
+
     // Set data and mark it as cached
-    query.dispatch({ type: actionSuccess, updater })
+    query.dispatch({
+      type: actionSuccess,
+      updater,
+      isStale,
+    })
 
-    // Schedule a fresh invalidation!
-    query.scheduleStaleTimeout()
+    if (!isStale) {
+      // Schedule a fresh invalidation!
+      query.scheduleStaleTimeout()
+    }
   }
 
   query.clear = () => {
@@ -172,11 +186,11 @@ export function makeQuery({
   const tryFetchData = async (fn, ...args) => {
     try {
       // Perform the query
-      const promise = fn(...query.config.queryFnParamsFilter(args))
+      const promiseOrValue = fn(...query.config.queryFnParamsFilter(args))
 
-      query.cancelPromises = () => promise.cancel?.()
+      query.cancelPromises = () => promiseOrValue?.cancel?.()
 
-      const data = await promise
+      const data = await promiseOrValue
       delete query.shouldContinueRetryOnFocus
 
       delete query.cancelPromises
@@ -187,16 +201,16 @@ export function makeQuery({
       delete query.cancelPromises
       if (query.cancelled) throw query.cancelled
 
-      // If we fail, increase the failureCount
-      query.dispatch({ type: actionFailed })
-
       // Do we need to retry the request?
       if (
         query.config.retry === true ||
-        query.state.failureCount <= query.config.retry ||
+        query.state.failureCount < query.config.retry ||
         (typeof query.config.retry === 'function' &&
           query.config.retry(query.state.failureCount, error))
       ) {
+        // If we retry, increase the failureCount
+        query.dispatch({ type: actionFailed })
+
         // Only retry if the document is visible
         if (!isDocumentVisible()) {
           // set this flag to continue retries on focus
@@ -454,7 +468,7 @@ function switchActions(state, action) {
         status: statusSuccess,
         data: functionalUpdate(action.updater, state.data),
         error: null,
-        isStale: false,
+        isStale: action.isStale,
         isFetching: false,
         updatedAt: Date.now(),
         failureCount: 0,
@@ -462,6 +476,7 @@ function switchActions(state, action) {
     case actionError:
       return {
         ...state,
+        failureCount: state.failureCount + 1,
         isFetching: false,
         isStale: true,
         ...(!action.cancelled && {
diff --git a/src/core/utils.js b/src/core/utils.js
index 4ff8e3dae..03557e017 100644
--- a/src/core/utils.js
+++ b/src/core/utils.js
@@ -90,18 +90,33 @@ export function getQueryArgs(args) {
   return [queryKey, queryFn ? { ...config, queryFn } : config, ...rest]
 }
 
+export function deepEqual(a, b) {
+  return equal(a, b, true)
+}
+
+export function shallowEqual(a, b) {
+  return equal(a, b, false)
+}
+
 // This deep-equal is directly based on https://github.com/epoberezkin/fast-deep-equal.
 // The parts for comparing any non-JSON-supported values has been removed
-export function deepEqual(a, b) {
+function equal(a, b, deep, depth = 0) {
   if (a === b) return true
 
-  if (a && b && typeof a == 'object' && typeof b == 'object') {
+  if (
+    (deep || !depth) &&
+    a &&
+    b &&
+    typeof a == 'object' &&
+    typeof b == 'object'
+  ) {
     var length, i, keys
     if (Array.isArray(a)) {
       length = a.length
       // eslint-disable-next-line eqeqeq
       if (length != b.length) return false
-      for (i = length; i-- !== 0; ) if (!deepEqual(a[i], b[i])) return false
+      for (i = length; i-- !== 0; )
+        if (!equal(a[i], b[i], deep, depth + 1)) return false
       return true
     }
 
@@ -118,7 +133,7 @@ export function deepEqual(a, b) {
     for (i = length; i-- !== 0; ) {
       var key = keys[i]
 
-      if (!deepEqual(a[key], b[key])) return false
+      if (!equal(a[key], b[key], deep, depth + 1)) return false
     }
 
     return true
diff --git a/src/react/useBaseQuery.js b/src/react/useBaseQuery.js
index 2247df193..e3341ba41 100644
--- a/src/react/useBaseQuery.js
+++ b/src/react/useBaseQuery.js
@@ -3,11 +3,11 @@ import React from 'react'
 //
 
 import { useQueryCache } from './ReactQueryCacheProvider'
-import { useMountedCallback } from './utils'
+import { useRerenderer } from './utils'
 
 export function useBaseQuery(queryKey, config = {}) {
   // Make a rerender function
-  const rerender = useMountedCallback(React.useState()[1])
+  const rerender = useRerenderer()
 
   // Get the query cache
   const queryCache = useQueryCache()
@@ -20,7 +20,9 @@ export function useBaseQuery(queryKey, config = {}) {
 
   // Subscribe to the query when the subscribe function changes
   React.useEffect(() => {
-    instanceRef.current = query.subscribe(() => rerender({}))
+    instanceRef.current = query.subscribe(() => {
+      rerender()
+    })
 
     // Unsubscribe when things change
     return instanceRef.current.unsubscribe
@@ -41,15 +43,9 @@ export function useBaseQuery(queryKey, config = {}) {
     instanceRef.current.run()
   }, [enabledBool, query])
 
-  const queryState = query.state
-
-  const queryInfoRef = React.useRef({})
-
-  Object.assign(queryInfoRef.current, {
+  return {
     ...query,
-    ...queryState,
+    ...query.state,
     query,
-  })
-
-  return queryInfoRef.current
+  }
 }
diff --git a/src/react/useIsFetching.js b/src/react/useIsFetching.js
index 511f9190d..33230e704 100644
--- a/src/react/useIsFetching.js
+++ b/src/react/useIsFetching.js
@@ -1,24 +1,21 @@
 import React from 'react'
 
-import { useMountedCallback, useGetLatest } from './utils'
+import { useRerenderer, useGetLatest } from './utils'
 import { useQueryCache } from './ReactQueryCacheProvider'
 
 export function useIsFetching() {
   const queryCache = useQueryCache()
-  const [state, unsafeRerender] = React.useReducer(d => d + 1, 1)
-  const rerender = useMountedCallback(unsafeRerender)
-
-  const isFetching = React.useMemo(() => state && queryCache.isFetching, [
-    queryCache.isFetching,
-    state,
-  ])
+  const rerender = useRerenderer()
+  const isFetching = queryCache.isFetching
 
   const getIsFetching = useGetLatest(isFetching)
 
   React.useEffect(
     () =>
       queryCache.subscribe(newCache => {
-        if (getIsFetching() !== newCache.isFetching) rerender()
+        if (getIsFetching() !== newCache.isFetching) {
+          rerender()
+        }
       }),
     [getIsFetching, queryCache, rerender]
   )
diff --git a/src/react/utils.js b/src/react/utils.js
index 6d62d75d7..25d9ae79d 100644
--- a/src/react/utils.js
+++ b/src/react/utils.js
@@ -56,25 +56,23 @@ export function useMountedCallback(callback) {
   )
 }
 
+export function useRerenderer() {
+  const rerender = useMountedCallback(React.useState()[1])
+  return React.useCallback(() => rerender({}), [rerender])
+}
+
 export function handleSuspense(queryInfo) {
-  if (
-    queryInfo.query.config.suspense ||
-    queryInfo.query.config.useErrorBoundary
-  ) {
-    if (
-      queryInfo.query.state.status === statusError &&
-      queryInfo.query.state.throwInErrorBoundary
-    ) {
-      throw queryInfo.error
+  const { error, query } = queryInfo
+  const { config, state } = query
+
+  if (config.suspense || config.useErrorBoundary) {
+    if (state.status === statusError && state.throwInErrorBoundary) {
+      throw error
     }
 
-    if (
-      queryInfo.query.config.suspense &&
-      queryInfo.status !== statusSuccess &&
-      queryInfo.query.config.enabled
-    ) {
-      queryInfo.query.wasSuspended = true
-      throw queryInfo.query.fetch()
+    if (config.suspense && state.status !== statusSuccess && config.enabled) {
+      query.wasSuspended = true
+      throw query.fetch()
     }
   }
 }


Test patch:
diff --git a/src/core/tests/queryCache.test.js b/src/core/tests/queryCache.test.js
index 35cd00f34..fd5cb3c96 100644
--- a/src/core/tests/queryCache.test.js
+++ b/src/core/tests/queryCache.test.js
@@ -47,7 +47,7 @@ describe('queryCache', () => {
   })
 
   test('prefetchQuery should not force fetch', async () => {
-    queryCache.setQueryData('key', 'og')
+    queryCache.setQueryData('key', 'og', { staleTime: 100 })
     const fetchFn = () => Promise.resolve('new')
     const first = await queryCache.prefetchQuery(
       'key',
@@ -77,17 +77,19 @@ describe('queryCache', () => {
     ).rejects.toEqual(new Error('error'))
   })
 
-  test('should notify listeners when new query is added', () => {
+  test('should notify listeners when new query is added', async () => {
     const callback = jest.fn()
 
     queryCache.subscribe(callback)
 
     queryCache.prefetchQuery('test', () => 'data')
 
+    await sleep(100)
+
     expect(callback).toHaveBeenCalled()
   })
 
-  test('should include the queryCache and query when notifying listeners', () => {
+  test('should include the queryCache and query when notifying listeners', async () => {
     const callback = jest.fn()
 
     queryCache.subscribe(callback)
@@ -95,6 +97,8 @@ describe('queryCache', () => {
     queryCache.prefetchQuery('test', () => 'data')
     const query = queryCache.getQuery('test')
 
+    await sleep(100)
+
     expect(callback).toHaveBeenCalledWith(queryCache, query)
   })
 
@@ -136,6 +140,21 @@ describe('queryCache', () => {
     expect(queryCache.getQuery('key')).toBeFalsy()
   })
 
+  test('setQueryData should schedule stale timeout, if staleTime is set', async () => {
+    queryCache.setQueryData('key', 'test data', { staleTime: 10 })
+    expect(queryCache.getQuery('key').staleTimeout).not.toBeUndefined()
+  })
+
+  test('setQueryData should not schedule stale timeout by default', async () => {
+    queryCache.setQueryData('key', 'test data')
+    expect(queryCache.getQuery('key').staleTimeout).toBeUndefined()
+  })
+
+  test('setQueryData should not schedule stale timeout, if staleTime is set to `Infinity`', async () => {
+    queryCache.setQueryData('key', 'test data', { staleTime: Infinity })
+    expect(queryCache.getQuery('key').staleTimeout).toBeUndefined()
+  })
+
   test('setQueryData schedules stale timeouts appropriately', async () => {
     queryCache.setQueryData('key', 'test data', { staleTime: 100 })
 
@@ -174,7 +193,7 @@ describe('queryCache', () => {
   test('stale timeout dispatch is not called if query is no longer in the query cache', async () => {
     const queryKey = 'key'
     const fetchData = () => Promise.resolve('data')
-    await queryCache.prefetchQuery(queryKey, fetchData)
+    await queryCache.prefetchQuery(queryKey, fetchData, { staleTime: 100 })
     const query = queryCache.getQuery(queryKey)
     expect(query.state.isStale).toBe(false)
     queryCache.removeQueries(queryKey)
diff --git a/src/core/tests/utils.test.js b/src/core/tests/utils.test.js
index 33317682c..ecbac7997 100644
--- a/src/core/tests/utils.test.js
+++ b/src/core/tests/utils.test.js
@@ -1,5 +1,5 @@
 import { setConsole, queryCache, queryCaches } from '../'
-import { deepEqual } from '../utils'
+import { deepEqual, shallowEqual } from '../utils'
 
 describe('core/utils', () => {
   afterEach(() => {
@@ -28,17 +28,76 @@ describe('core/utils', () => {
     setConsole(console)
   })
 
-  it('deepequal should return `false` for different dates', () => {
-    const date1 = new Date(2020, 3, 1)
-    const date2 = new Date(2020, 3, 2)
+  describe('deepEqual', () => {
+    it('should return `true` for equal objects', () => {
+      const a = { a: { b: 'b' }, c: 'c', d: [{ d: 'd ' }] }
+      const b = { a: { b: 'b' }, c: 'c', d: [{ d: 'd ' }] }
+      expect(deepEqual(a, b)).toEqual(true)
+    })
 
-    expect(deepEqual(date1, date2)).toEqual(false)
+    it('should return `false` for non equal objects', () => {
+      const a = { a: { b: 'b' }, c: 'c' }
+      const b = { a: { b: 'c' }, c: 'c' }
+      expect(deepEqual(a, b)).toEqual(false)
+    })
+
+    it('should return `false` for different dates', () => {
+      const date1 = new Date(2020, 3, 1)
+      const date2 = new Date(2020, 3, 2)
+      expect(deepEqual(date1, date2)).toEqual(false)
+    })
+
+    it('return `true` for equal dates', () => {
+      const date1 = new Date(2020, 3, 1)
+      const date2 = new Date(2020, 3, 1)
+      expect(deepEqual(date1, date2)).toEqual(true)
+    })
   })
 
-  it('should return `true` for equal dates', () => {
-    const date1 = new Date(2020, 3, 1)
-    const date2 = new Date(2020, 3, 1)
+  describe('shallowEqual', () => {
+    it('should return `true` for empty objects', () => {
+      expect(shallowEqual({}, {})).toEqual(true)
+    })
+
+    it('should return `true` for equal values', () => {
+      expect(shallowEqual(1, 1)).toEqual(true)
+    })
+
+    it('should return `true` for equal arrays', () => {
+      expect(shallowEqual([1, 2], [1, 2])).toEqual(true)
+    })
+
+    it('should return `true` for equal shallow objects', () => {
+      const a = { a: 'a', b: 'b' }
+      const b = { a: 'a', b: 'b' }
+      expect(shallowEqual(a, b)).toEqual(true)
+    })
+
+    it('should return `true` for equal deep objects with same identities', () => {
+      const deep = { b: 'b' }
+      const a = { a: deep, c: 'c' }
+      const b = { a: deep, c: 'c' }
+      expect(shallowEqual(a, b)).toEqual(true)
+    })
+
+    it('should return `false` for non equal values', () => {
+      expect(shallowEqual(1, 2)).toEqual(false)
+    })
+
+    it('should return `false` for equal arrays', () => {
+      expect(shallowEqual([1, 2], [1, 3])).toEqual(false)
+    })
+
+    it('should return `false` for non equal shallow objects', () => {
+      const a = { a: 'a', b: 'b' }
+      const b = { a: 'a', b: 'c' }
+      expect(shallowEqual(a, b)).toEqual(false)
+    })
 
-    expect(deepEqual(date1, date2)).toEqual(true)
+    it('should return `false` for equal deep objects with different identities', () => {
+      const a = { a: { b: 'b' }, c: 'c' }
+      const b = { a: { b: 'b' }, c: 'c' }
+      expect(shallowEqual(a, b)).toEqual(false)
+    })
   })
 })
diff --git a/src/react/tests/useInfiniteQuery.test.js b/src/react/tests/useInfiniteQuery.test.js
index 8cc452df0..1bb75c8b4 100644
--- a/src/react/tests/useInfiniteQuery.test.js
+++ b/src/react/tests/useInfiniteQuery.test.js
@@ -28,6 +28,72 @@ describe('useInfiniteQuery', () => {
     queryCaches.forEach(cache => cache.clear({ notify: false }))
   })
 
+  it('should return the correct states for a successful query', async () => {
+    let count = 0
+    const states = []
+
+    function Page() {
+      const state = useInfiniteQuery(
+        'items',
+        (key, nextId = 0) => fetchItems(nextId, count++),
+        {
+          getFetchMore: (lastGroup, allGroups) => Boolean(lastGroup.nextId),
+        }
+      )
+
+      states.push(state)
+
+      return (
+        <div>
+          <h1>Status: {state.status}</h1>
+        </div>
+      )
+    }
+
+    const rendered = render(<Page />)
+
+    await waitFor(() => rendered.getByText('Status: success'))
+
+    expect(states[0]).toMatchObject({
+      clear: expect.any(Function),
+      data: undefined,
+      error: null,
+      failureCount: 0,
+      fetchMore: expect.any(Function),
+      isError: false,
+      isFetching: true,
+      isIdle: false,
+      isLoading: true,
+      isStale: true,
+      isSuccess: false,
+      refetch: expect.any(Function),
+      status: 'loading',
+    })
+
+    expect(states[1]).toMatchObject({
+      clear: expect.any(Function),
+      canFetchMore: true,
+      data: [
+        {
+          items: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
+          nextId: 1,
+          ts: 0,
+        },
+      ],
+      error: null,
+      failureCount: 0,
+      fetchMore: expect.any(Function),
+      isError: false,
+      isFetching: false,
+      isIdle: false,
+      isLoading: false,
+      isStale: true,
+      isSuccess: true,
+      refetch: expect.any(Function),
+      status: 'success',
+    })
+  })
+
   it('should allow you to fetch more pages', async () => {
     function Page() {
       const fetchCountRef = React.useRef(0)
diff --git a/src/react/tests/usePaginatedQuery.test.js b/src/react/tests/usePaginatedQuery.test.js
index 0709be0ed..a355b5260 100644
--- a/src/react/tests/usePaginatedQuery.test.js
+++ b/src/react/tests/usePaginatedQuery.test.js
@@ -9,6 +9,63 @@ describe('usePaginatedQuery', () => {
     queryCaches.forEach(cache => cache.clear({ notify: false }))
   })
 
+  it('should return the correct states for a successful query', async () => {
+    const states = []
+
+    function Page() {
+      const state = usePaginatedQuery(['data', 1], async (queryName, page) => {
+        await sleep(10)
+        return page
+      })
+
+      states.push(state)
+
+      return (
+        <div>
+          <h1>Status: {state.status}</h1>
+        </div>
+      )
+    }
+
+    const rendered = render(<Page />)
+
+    await waitFor(() => rendered.getByText('Status: success'))
+
+    expect(states[0]).toMatchObject({
+      clear: expect.any(Function),
+      data: undefined,
+      error: null,
+      failureCount: 0,
+      isError: false,
+      isFetching: true,
+      isIdle: false,
+      isLoading: true,
+      isStale: true,
+      isSuccess: false,
+      latestData: undefined,
+      resolvedData: undefined,
+      refetch: expect.any(Function),
+      status: 'loading',
+    })
+
+    expect(states[1]).toMatchObject({
+      clear: expect.any(Function),
+      data: 1,
+      error: null,
+      failureCount: 0,
+      isError: false,
+      isFetching: false,
+      isIdle: false,
+      isLoading: false,
+      isStale: true,
+      isSuccess: true,
+      latestData: 1,
+      resolvedData: 1,
+      refetch: expect.any(Function),
+      status: 'success',
+    })
+  })
+
   it('should use previous page data while fetching the next page', async () => {
     function Page() {
       const [page, setPage] = React.useState(1)
diff --git a/src/react/tests/useQuery.test.js b/src/react/tests/useQuery.test.js
index 4efea9685..9e35196cd 100644
--- a/src/react/tests/useQuery.test.js
+++ b/src/react/tests/useQuery.test.js
@@ -33,21 +33,127 @@ describe('useQuery', () => {
     await waitFor(() => rendered.getByText('test'))
   })
 
-  it('should memoize the query info object', async () => {
+  it('should return the correct states for a successful query', async () => {
     const queryKey = Math.random()
     const states = []
 
     function Page() {
-      const state = useQuery(queryKey, () => 'test', { enabled: false })
+      const state = useQuery(queryKey, () => 'test')
+
       states.push(state)
-      return null
+
+      return (
+        <div>
+          <h1>Status: {state.status}</h1>
+        </div>
+      )
+    }
+
+    const rendered = render(<Page />)
+
+    await waitFor(() => rendered.getByText('Status: success'))
+
+    expect(states[0]).toMatchObject({
+      clear: expect.any(Function),
+      data: undefined,
+      error: null,
+      failureCount: 0,
+      isError: false,
+      isFetching: true,
+      isIdle: false,
+      isLoading: true,
+      isStale: true,
+      isSuccess: false,
+      refetch: expect.any(Function),
+      status: 'loading',
+    })
+
+    expect(states[1]).toMatchObject({
+      clear: expect.any(Function),
+      data: 'test',
+      error: null,
+      failureCount: 0,
+      isError: false,
+      isFetching: false,
+      isIdle: false,
+      isLoading: false,
+      isStale: true,
+      isSuccess: true,
+      refetch: expect.any(Function),
+      status: 'success',
+    })
+  })
+
+  it('should return the correct states for an unsuccessful query', async () => {
+    jest.spyOn(console, 'error')
+    console.error.mockImplementation(() => {})
+
+    const queryKey = Math.random()
+    const states = []
+
+    function Page() {
+      const state = useQuery(queryKey, () => Promise.reject('rejected'), {
+        retry: 1,
+        retryDelay: 1,
+      })
+
+      states.push(state)
+
+      return (
+        <div>
+          <h1>Status: {state.status}</h1>
+        </div>
+      )
     }
 
     const rendered = render(<Page />)
-    rendered.rerender(<Page />)
 
-    expect(states.length).toBe(2)
-    expect(states[0]).toBe(states[1])
+    await waitFor(() => rendered.getByText('Status: error'))
+
+    expect(states[0]).toMatchObject({
+      clear: expect.any(Function),
+      data: undefined,
+      error: null,
+      failureCount: 0,
+      isError: false,
+      isFetching: true,
+      isIdle: false,
+      isLoading: true,
+      isStale: true,
+      isSuccess: false,
+      refetch: expect.any(Function),
+      status: 'loading',
+    })
+
+    expect(states[1]).toMatchObject({
+      clear: expect.any(Function),
+      data: undefined,
+      error: null,
+      failureCount: 1,
+      isError: false,
+      isFetching: true,
+      isIdle: false,
+      isLoading: true,
+      isStale: true,
+      isSuccess: false,
+      refetch: expect.any(Function),
+      status: 'loading',
+    })
+
+    expect(states[2]).toMatchObject({
+      clear: expect.any(Function),
+      data: undefined,
+      error: 'rejected',
+      failureCount: 2,
+      isError: true,
+      isFetching: false,
+      isIdle: false,
+      isLoading: false,
+      isStale: true,
+      isSuccess: false,
+      refetch: expect.any(Function),
+      status: 'error',
+    })
   })
 
   // See https://github.com/tannerlinsley/react-query/issues/137
@@ -631,52 +737,6 @@ describe('useQuery', () => {
     console.error.mockRestore()
   })
 
-  test('should schedule stale timeout by default', async () => {
-    function Page() {
-      const query = useQuery('test', () => 'fetched data')
-
-      return (
-        <div>
-          <div>{query.data}</div>
-          <div data-testid="isStale">{`${query.isStale}`}</div>
-          <div data-testid="staleTimeout">{`${query.query.staleTimeout}`}</div>
-        </div>
-      )
-    }
-
-    const rendered = render(<Page />)
-
-    await waitFor(() => rendered.getByText('fetched data'))
-    await waitFor(() => {
-      expect(rendered.getByTestId('isStale').textContent).toBe('true')
-    })
-    expect(rendered.getByTestId('staleTimeout').textContent).not.toBe(
-      'undefined'
-    )
-  })
-
-  test('should not schedule stale timeout, if staleTime is set to `Infinity`', async () => {
-    function Page() {
-      const query = useQuery('test', () => 'fetched data', {
-        staleTime: Infinity,
-      })
-
-      return (
-        <div>
-          <div>{query.data}</div>
-          <div data-testid="isStale">{`${query.isStale}`}</div>
-          <div data-testid="staleTimeout">{`${query.query.staleTimeout}`}</div>
-        </div>
-      )
-    }
-
-    const rendered = render(<Page />)
-
-    await waitFor(() => rendered.getByText('fetched data'))
-    expect(rendered.getByTestId('isStale').textContent).toBe('false')
-    expect(rendered.getByTestId('staleTimeout').textContent).toBe('undefined')
-  })
-
   // See https://github.com/tannerlinsley/react-query/issues/360
   test('should init to status:idle when enabled is falsey', async () => {
     function Page() {
@@ -763,27 +823,22 @@ describe('useQuery', () => {
       const { data } = useQuery('/api', () => count++, {
         refetchInterval: int,
       })
-      return (
-        <div onClick={() => setInt(num => (num < 400 ? num + 100 : 0))}>
-          count: {data}
-        </div>
-      )
+
+      React.useEffect(() => {
+        if (data === 2) {
+          setInt(0)
+        }
+      }, [data])
+
+      return <div>count: {data}</div>
     }
 
     const rendered = render(<Page />)
 
-    rendered.getByText('count:')
-
     // mount
     await waitFor(() => rendered.getByText('count: 0'))
     await waitFor(() => rendered.getByText('count: 1'))
-    await waitFor(() => rendered.getByText('count: 1'))
-    await waitFor(() => rendered.getByText('count: 2'))
     await waitFor(() => rendered.getByText('count: 2'))
-    await waitFor(() => rendered.getByText('count: 3'))
-    await waitFor(() => rendered.getByText('count: 4'))
-    await waitFor(() => rendered.getByText('count: 5'))
-    await waitFor(() => rendered.getByText('count: 5'))
   })
 
   it('should error when using functions as query keys', () => {