Repository: tannerlinsley/react-query
Pull request #3141 (2021-12-28 19:40:30)

Break down `persistQueryClient`
Per discussion #3129 

1. Break down `persistQueryClient` into its various tasks
2. Return an unsubscribe callback when subscribing a persister
3. Update docs

holding on the other updates to keep it more straightforward for now.


Patch:
diff --git a/docs/src/pages/guides/migrating-to-react-query-4.md b/docs/src/pages/guides/migrating-to-react-query-4.md
index 8bffe466f..a2ac3d02e 100644
--- a/docs/src/pages/guides/migrating-to-react-query-4.md
+++ b/docs/src/pages/guides/migrating-to-react-query-4.md
@@ -217,9 +217,9 @@ The `useQueries` hook now accepts an object with a `queries` prop as its input.
 ```
 
 
-### Removed undocumented methods from the `queryClient`
+### Removed undocumented methods from the `queryClient` and `query`
 
-The methods `cancelMutatations` and `executeMutation` were undocumented and unused internally, so we removed them. Since they were just wrappers around methods available on the `mutationCache`, you can still use the functionality.
+The methods `cancelMutatations` and `executeMutation` on the `QueryClient` were undocumented and unused internally, so we removed them. Since they were just wrappers around methods available on the `mutationCache`, you can still use the functionality.
 
 ```diff
 - cancelMutations(): Promise<void> {
@@ -243,6 +243,8 @@ The methods `cancelMutatations` and `executeMutation` were undocumented and unus
 - }
 ```
 
+Additionally, `query.setDefaultOptions` was removed because it was also unused.
+
 ### TypeScript
 
 Types now require using TypeScript v4.1 or greater
diff --git a/docs/src/pages/plugins/persistQueryClient.md b/docs/src/pages/plugins/persistQueryClient.md
index 969ea9880..75aaac23a 100644
--- a/docs/src/pages/plugins/persistQueryClient.md
+++ b/docs/src/pages/plugins/persistQueryClient.md
@@ -30,7 +30,9 @@ const queryClient = new QueryClient({
   },
 })
 
-const localStoragePersister = createWebStoragePersister({storage: window.localStorage})
+const localStoragePersister = createWebStoragePersister({
+  storage: window.localStorage,
+})
 
 persistQueryClient({
   queryClient,
@@ -48,16 +50,15 @@ You can also pass it `Infinity` to disable garbage collection behavior entirely.
 
 ## How does it work?
 
-As you use your application:
+- A check for window `undefined` is performed prior to saving/restoring/removing your data (avoids build errors).
 
-- When your query/mutation cache is updated, it will be dehydrated and stored by the persister you provided. **By default**, this action is throttled to happen at most every 1 second to save on potentially expensive writes to a persister, but can be customized as you see fit.
+### Storing
 
-When you reload/bootstrap your app:
+As you use your application:
 
-- Attempts to load a previously persisted dehydrated query/mutation cache from the persister
-- If a cache is found that is older than the `maxAge` (which by default is 24 hours), it will be discarded. This can be customized as you see fit.
+- When your query/mutation cache is updated, it will be [`dehydrated`](../reference/hydration#dehydrate) and stored by the persister you provided. The officially supported persisters throttle this action to happen at most every 1 second to save on potentially expensive writes, but can be customized as you see fit.
 
-## Cache Busting
+#### Cache Busting
 
 Sometimes you may make changes to your application or data that immediately invalidate any and all cached data. If and when this happens, you can pass a `buster` string option to `persistQueryClient`, and if the cache that is found does not also have that buster string, it will be discarded.
 
@@ -65,14 +66,75 @@ Sometimes you may make changes to your application or data that immediately inva
 persistQueryClient({ queryClient, persister, buster: buildHash })
 ```
 
+### Restoring
+
+When you reload/bootstrap your app:
+
+- Attempts to [`hydrate`](../reference/hydration#hydrate) a previously persisted dehydrated query/mutation cache from the persister back into the query cache of the passed query client.
+- If a cache is found that is older than the `maxAge` (which by default is 24 hours), it will be discarded. This can be customized as you see fit.
+
+### Removal
+
+- If data is found to be expired (see `maxAge`), busted (see `buster`), error (ex: `throws ...`), or empty (ex: `undefined`), the persister `removeClient()` is called and the cache is immediately discarded.
+
 ## API
 
+### `persistQueryClientRestore`
+
+This will attempt to restore a persister's stored cached to the query cache of the passed queryClient.
+
+```ts
+persistQueryClientRestore({
+  queryClient,
+  persister,
+  maxAge = 1000 * 60 * 60 * 24, // 24 hours
+  buster = '',
+  hydrateOptions = undefined,
+})
+```
+
+### `persistQueryClientSave`
+
+This will attempt to save the current query cache with the persister. You can use this to explicitly persist the cache at the moments you choose.
+
+```ts
+persistQueryClientSave({
+  queryClient,
+  persister,
+  buster = '',
+  dehydrateOptions = undefined,
+})
+```
+
+### `persistQueryClientSubscribe`
+
+This will subscribe to query cache updates which will run `persistQueryClientSave`. For example: you might initiate the `subscribe` when a user logs-in and checks "Remember me".
+
+- It returns an `unsubscribe` function which you can use to discontinue the monitor; ending the updates to the persisted cache.
+- If you want to erase the persisted cache after the `unsubscribe`, you can send a new `buster` to `persistQueryClientRestore` which will trigger the persister's `removeClient` function and discard the persisted cache.
+
+```ts
+persistQueryClientSubscribe({
+  queryClient,
+  persister,
+  buster = '',
+  dehydrateOptions = undefined,
+})
+```
+
 ### `persistQueryClient`
 
-Pass this function a `QueryClient` instance and a persister that will persist your cache. Both are **required**
+This will automatically restore any persisted cache and subscribes to the query cache to persist any changes from the query cache to the persister. It returns an `unsubscribe` function which you can use to discontinue the monitor; ending the updates to the persisted cache.
 
 ```ts
-persistQueryClient({ queryClient, persister })
+persistQueryClient({
+  queryClient,
+  persister,
+  maxAge = 1000 * 60 * 60 * 24, // 24 hours
+  buster = '',
+  hydrateOptions = undefined,
+  dehydrateOptions = undefined,
+})
 ```
 
 ### `Options`
@@ -86,9 +148,10 @@ interface PersistQueryClientOptions {
   /** The Persister interface for storing and restoring the cache
    * to/from a persisted location */
   persister: Persister
-  /** The max-allowed age of the cache.
+  /** The max-allowed age of the cache in milliseconds.
    * If a persisted cache is found that is older than this
-   * time, it will be discarded */
+   * time, it will be **silently** discarded
+   * (defaults to 24 hours) */
   maxAge?: number
   /** A unique string that can be used to forcefully
    * invalidate existing caches if they do not share the same buster string */
@@ -100,15 +163,6 @@ interface PersistQueryClientOptions {
 }
 ```
 
-The default options are:
-
-```ts
-{
-  maxAge = 1000 * 60 * 60 * 24, // 24 hours
-  buster = '',
-}
-```
-
 ## Building a Persister
 
 Persisters have the following interface:
diff --git a/docs/src/pages/reference/hydration.md b/docs/src/pages/reference/hydration.md
index f1694fb63..a81543275 100644
--- a/docs/src/pages/reference/hydration.md
+++ b/docs/src/pages/reference/hydration.md
@@ -48,7 +48,7 @@ const dehydratedState = dehydrate(queryClient, {
 
 ### limitations
 
-The hydration API requires values to be JSON serializable. If you need to dehydrate values that are not automatically serializable to JSON (like `Error` or `undefined`), you have to serialize them for yourself. Since only successful queries are included per default, to also include `Errors`, you have to provide `shouldDehydrateQuery`, e.g.:
+Some storage systems (such as browser [Web Storage API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API)) require values to be JSON serializable. If you need to dehydrate values that are not automatically serializable to JSON (like `Error` or `undefined`), you have to serialize them for yourself. Since only successful queries are included per default, to also include `Errors`, you have to provide `shouldDehydrateQuery`, e.g.:
 
 ```js
 // server
@@ -56,13 +56,13 @@ const state = dehydrate(client, { shouldDehydrateQuery: () => true }) // to also
 const serializedState = mySerialize(state) // transform Error instances to objects
 
 // client
-const state = myDeserialize(serializedState)  // transform objects back to Error instances
+const state = myDeserialize(serializedState) // transform objects back to Error instances
 hydrate(client, state)
 ```
 
 ## `hydrate`
 
-`hydrate` adds a previously dehydrated state into a `cache`. If the queries included in dehydration already exist in the queryCache, `hydrate` does not overwrite them.
+`hydrate` adds a previously dehydrated state into a `cache`.
 
 ```js
 import { hydrate } from 'react-query'
@@ -85,6 +85,10 @@ hydrate(queryClient, dehydratedState, options)
     - `mutations: MutationOptions` The default mutation options to use for the hydrated mutations.
     - `queries: QueryOptions` The default query options to use for the hydrated queries.
 
+### Limitations
+
+If the queries included in dehydration already exist in the queryCache, `hydrate` does not overwrite them and they will be **silently** discarded.
+
 ## `useHydrate`
 
 `useHydrate` adds a previously dehydrated state into the `queryClient` that would be returned by `useQueryClient()`. If the client already contains data, the new queries will be intelligently merged based on update timestamp.
diff --git a/examples/playground/src/index.js b/examples/playground/src/index.js
index ee96cf5f9..886d24c18 100644
--- a/examples/playground/src/index.js
+++ b/examples/playground/src/index.js
@@ -181,7 +181,7 @@ function Todos({ initialFilter = "", setEditingIndex }) {
 
   const { status, data, isFetching, error, failureCount, refetch } = useQuery(
     ["todos", { filter }],
-    () => fetchTodos({ filter })
+    fetchTodos
   );
 
   return (
@@ -370,9 +370,16 @@ function AddTodo() {
   );
 }
 
-function fetchTodos({ filter } = {}) {
+function fetchTodos({ signal, queryKey: [, { filter }] }) {
   console.info("fetchTodos", { filter });
-  const promise = new Promise((resolve, reject) => {
+
+  if (signal) {
+    signal.addEventListener("abort", () => {
+      console.info("cancelled", filter);
+    });
+  }
+
+  return new Promise((resolve, reject) => {
     setTimeout(() => {
       if (Math.random() < errorRate) {
         return reject(
@@ -382,10 +389,6 @@ function fetchTodos({ filter } = {}) {
       resolve(list.filter((d) => d.name.includes(filter)));
     }, queryTimeMin + Math.random() * (queryTimeMax - queryTimeMin));
   });
-
-  promise.cancel = () => console.info("cancelled", filter);
-
-  return promise;
 }
 
 function fetchTodoById({ id }) {
@@ -397,7 +400,7 @@ function fetchTodoById({ id }) {
           new Error(JSON.stringify({ fetchTodoById: { id } }, null, 2))
         );
       }
-      resolve(list.find((d) => d.id == id));
+      resolve(list.find((d) => d.id === id));
     }, queryTimeMin + Math.random() * (queryTimeMax - queryTimeMin));
   });
 }
diff --git a/src/core/query.ts b/src/core/query.ts
index f82b0281f..195cfa556 100644
--- a/src/core/query.ts
+++ b/src/core/query.ts
@@ -188,12 +188,6 @@ export class Query<
     this.updateCacheTime(this.options.cacheTime)
   }
 
-  setDefaultOptions(
-    options: QueryOptions<TQueryFnData, TError, TData, TQueryKey>
-  ): void {
-    this.defaultOptions = options
-  }
-
   protected optionalRemove() {
     if (!this.observers.length && this.state.fetchStatus === 'idle') {
       this.cache.remove(this)
@@ -572,7 +566,10 @@ export class Query<
           fetchStatus: canFetch(this.options.networkMode)
             ? 'fetching'
             : 'paused',
-          status: !state.dataUpdatedAt ? 'loading' : state.status,
+          ...(!state.dataUpdatedAt && {
+            error: null,
+            status: 'loading',
+          }),
         }
       case 'success':
         return {
diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts
index c2a249a9a..587be13fb 100644
--- a/src/core/queryObserver.ts
+++ b/src/core/queryObserver.ts
@@ -69,7 +69,10 @@ export class QueryObserver<
   >
   private previousQueryResult?: QueryObserverResult<TData, TError>
   private previousSelectError: TError | null
-  private previousSelectFn?: (data: TQueryData) => TData
+  private previousSelect?: {
+    fn: (data: TQueryData) => TData
+    result: TData
+  }
   private staleTimeoutId?: ReturnType<typeof setTimeout>
   private refetchIntervalId?: ReturnType<typeof setInterval>
   private currentRefetchInterval?: number | false
@@ -473,14 +476,17 @@ export class QueryObserver<
       if (
         prevResult &&
         state.data === prevResultState?.data &&
-        options.select === this.previousSelectFn &&
+        options.select === this.previousSelect?.fn &&
         !this.previousSelectError
       ) {
-        data = prevResult.data
+        data = this.previousSelect.result
       } else {
         try {
-          this.previousSelectFn = options.select
           data = options.select(state.data)
+          this.previousSelect = {
+            fn: options.select,
+            result: data,
+          }
           if (options.structuralSharing !== false) {
             data = replaceEqualDeep(prevResult?.data, data)
           }
diff --git a/src/devtools/styledComponents.ts b/src/devtools/styledComponents.ts
index 18968629c..0f4171502 100644
--- a/src/devtools/styledComponents.ts
+++ b/src/devtools/styledComponents.ts
@@ -65,6 +65,7 @@ export const QueryKey = styled('span', {
 
 export const Code = styled('code', {
   fontSize: '.9em',
+  color: 'inherit',
 })
 
 export const Input = styled('input', (_props, theme) => ({
diff --git a/src/persistQueryClient/index.ts b/src/persistQueryClient/index.ts
index 8380228d5..d38ed924b 100644
--- a/src/persistQueryClient/index.ts
+++ b/src/persistQueryClient/index.ts
@@ -21,46 +21,52 @@ export interface PersistedClient {
   clientState: DehydratedState
 }
 
-export interface PersistQueryClientOptions {
+export interface PersistQueryClienRootOptions {
   /** The QueryClient to persist */
   queryClient: QueryClient
   /** The Persister interface for storing and restoring the cache
    * to/from a persisted location */
   persister: Persister
-  /** The max-allowed age of the cache.
-   * If a persisted cache is found that is older than this
-   * time, it will be discarded */
-  maxAge?: number
   /** A unique string that can be used to forcefully
    * invalidate existing caches if they do not share the same buster string */
   buster?: string
+}
+
+export interface PersistedQueryClientRestoreOptions
+  extends PersistQueryClienRootOptions {
+  /** The max-allowed age of the cache in milliseconds.
+   * If a persisted cache is found that is older than this
+   * time, it will be discarded */
+  maxAge?: number
   /** The options passed to the hydrate function */
   hydrateOptions?: HydrateOptions
+}
+
+export interface PersistedQueryClientSaveOptions
+  extends PersistQueryClienRootOptions {
   /** The options passed to the dehydrate function */
   dehydrateOptions?: DehydrateOptions
 }
 
-export async function persistQueryClient({
+export interface PersistQueryClientOptions
+  extends PersistedQueryClientRestoreOptions,
+    PersistedQueryClientSaveOptions,
+    PersistQueryClienRootOptions {}
+
+/**
+ * Restores persisted data to the QueryCache
+ *  - data obtained from persister.restoreClient
+ *  - data is hydrated using hydrateOptions
+ * If data is expired, busted, empty, or throws, it runs persister.removeClient
+ */
+export async function persistQueryClientRestore({
   queryClient,
   persister,
   maxAge = 1000 * 60 * 60 * 24,
   buster = '',
   hydrateOptions,
-  dehydrateOptions,
-}: PersistQueryClientOptions) {
+}: PersistedQueryClientRestoreOptions) {
   if (typeof window !== 'undefined') {
-    // Subscribe to changes
-    const saveClient = () => {
-      const persistClient: PersistedClient = {
-        buster,
-        timestamp: Date.now(),
-        clientState: dehydrate(queryClient, dehydrateOptions),
-      }
-
-      persister.persistClient(persistClient)
-    }
-
-    // Attempt restore
     try {
       const persistedClient = await persister.restoreClient()
 
@@ -84,8 +90,53 @@ export async function persistQueryClient({
       )
       persister.removeClient()
     }
+  }
+}
+
+/**
+ * Persists data from the QueryCache
+ *  - data dehydrated using dehydrateOptions
+ *  - data is persisted using persister.persistClient
+ */
+export async function persistQueryClientSave({
+  queryClient,
+  persister,
+  buster = '',
+  dehydrateOptions,
+}: PersistedQueryClientSaveOptions) {
+  if (typeof window !== 'undefined') {
+    const persistClient: PersistedClient = {
+      buster,
+      timestamp: Date.now(),
+      clientState: dehydrate(queryClient, dehydrateOptions),
+    }
+
+    await persister.persistClient(persistClient)
+  }
+}
+
+/**
+ * Subscribe to QueryCache updates (for persisting)
+ * @returns an unsubscribe function (to discontinue monitoring)
+ */
+export function persistQueryClientSubscribe(
+  props: PersistedQueryClientSaveOptions
+) {
+  return props.queryClient.getQueryCache().subscribe(() => {
+    persistQueryClientSave(props)
+  })
+}
+
+/**
+ * Restores persisted data to QueryCache and persists further changes.
+ * (Retained for backwards compatibility)
+ */
+export async function persistQueryClient(props: PersistQueryClientOptions) {
+  if (typeof window !== 'undefined') {
+    // Attempt restore
+    await persistQueryClientRestore(props)
 
     // Subscribe to changes in the query cache to trigger the save
-    queryClient.getQueryCache().subscribe(saveClient)
+    return persistQueryClientSubscribe(props)
   }
 }
diff --git a/src/reactjs/types.ts b/src/reactjs/types.ts
index 3d2079d11..13ac8c451 100644
--- a/src/reactjs/types.ts
+++ b/src/reactjs/types.ts
@@ -1,16 +1,12 @@
-import { RetryValue, RetryDelayValue } from '../core/retryer'
 import {
   InfiniteQueryObserverOptions,
   InfiniteQueryObserverResult,
   MutationObserverResult,
-  MutationKey,
   QueryObserverOptions,
   QueryObserverResult,
   QueryKey,
-  MutationFunction,
-  MutateOptions,
-  MutationMeta,
-  NetworkMode,
+  MutationObserverOptions,
+  MutateFunction,
 } from '../core/types'
 
 export interface UseBaseQueryOptions<
@@ -74,35 +70,10 @@ export interface UseMutationOptions<
   TError = unknown,
   TVariables = void,
   TContext = unknown
-> {
-  mutationFn?: MutationFunction<TData, TVariables>
-  mutationKey?: MutationKey
-  cacheTime?: number
-  onMutate?: (
-    variables: TVariables
-  ) => Promise<TContext | undefined> | TContext | undefined
-  onSuccess?: (
-    data: TData,
-    variables: TVariables,
-    context: TContext | undefined
-  ) => Promise<unknown> | void
-  onError?: (
-    error: TError,
-    variables: TVariables,
-    context: TContext | undefined
-  ) => Promise<unknown> | void
-  onSettled?: (
-    data: TData | undefined,
-    error: TError | null,
-    variables: TVariables,
-    context: TContext | undefined
-  ) => Promise<unknown> | void
-  retry?: RetryValue<TError>
-  retryDelay?: RetryDelayValue<TError>
-  networkMode?: NetworkMode
-  useErrorBoundary?: boolean | ((error: TError) => boolean)
-  meta?: MutationMeta
-}
+> extends Omit<
+    MutationObserverOptions<TData, TError, TVariables, TContext>,
+    '_defaulted' | 'variables'
+  > {}
 
 export type UseMutateFunction<
   TData = unknown,
@@ -110,8 +81,7 @@ export type UseMutateFunction<
   TVariables = void,
   TContext = unknown
 > = (
-  variables: TVariables,
-  options?: MutateOptions<TData, TError, TVariables, TContext>
+  ...args: Parameters<MutateFunction<TData, TError, TVariables, TContext>>
 ) => void
 
 export type UseMutateAsyncFunction<
@@ -119,10 +89,7 @@ export type UseMutateAsyncFunction<
   TError = unknown,
   TVariables = void,
   TContext = unknown
-> = (
-  variables: TVariables,
-  options?: MutateOptions<TData, TError, TVariables, TContext>
-) => Promise<TData>
+> = MutateFunction<TData, TError, TVariables, TContext>
 
 export type UseBaseMutationResult<
   TData = unknown,


Test patch:
diff --git a/src/core/tests/query.test.tsx b/src/core/tests/query.test.tsx
index 243225ae1..cc120b933 100644
--- a/src/core/tests/query.test.tsx
+++ b/src/core/tests/query.test.tsx
@@ -634,21 +634,6 @@ describe('query', () => {
     )
   })
 
-  test('should set default options', async () => {
-    const key = queryKey()
-
-    await queryClient.prefetchQuery(key, () => 'data')
-    const query = queryCache.find(key)!
-
-    query.setDefaultOptions({ retryDelay: 20 })
-
-    await queryClient.prefetchQuery(key, () => 'data', {
-      cacheTime: 100,
-    })
-
-    expect(query.options).toMatchObject({ cacheTime: 100, retryDelay: 20 })
-  })
-
   test('should refetch the observer when online method is called', async () => {
     const key = queryKey()
 
diff --git a/src/reactjs/tests/useQuery.test.tsx b/src/reactjs/tests/useQuery.test.tsx
index 193dc892b..c0803b4b9 100644
--- a/src/reactjs/tests/useQuery.test.tsx
+++ b/src/reactjs/tests/useQuery.test.tsx
@@ -4102,6 +4102,56 @@ describe('useQuery', () => {
     expect(selectRun).toBe(3)
   })
 
+  it('select should always return the correct state', async () => {
+    const key1 = queryKey()
+
+    function Page() {
+      const [count, inc] = React.useReducer(prev => prev + 1, 2)
+      const [forceValue, forceUpdate] = React.useReducer(prev => prev + 1, 1)
+
+      const state = useQuery(
+        key1,
+        async () => {
+          await sleep(10)
+          return 0
+        },
+        {
+          select: React.useCallback(
+            (data: number) => {
+              return `selected ${data + count}`
+            },
+            [count]
+          ),
+          placeholderData: 99,
+        }
+      )
+
+      return (
+        <div>
+          <h2>Data: {state.data}</h2>
+          <h2>forceValue: {forceValue}</h2>
+          <button onClick={inc}>inc: {count}</button>
+          <button onClick={forceUpdate}>forceUpdate</button>
+        </div>
+      )
+    }
+
+    const rendered = renderWithClient(queryClient, <Page />)
+    await waitFor(() => rendered.getByText('Data: selected 101')) // 99 + 2
+
+    await waitFor(() => rendered.getByText('Data: selected 2')) // 0 + 2
+
+    rendered.getByRole('button', { name: /inc/i }).click()
+
+    await waitFor(() => rendered.getByText('Data: selected 3')) // 0 + 3
+
+    rendered.getByRole('button', { name: /forceUpdate/i }).click()
+
+    await waitFor(() => rendered.getByText('forceValue: 2'))
+    // data should still be 3 after an independent re-render
+    await waitFor(() => rendered.getByText('Data: selected 3'))
+  })
+
   it('should cancel the query function when there are no more subscriptions', async () => {
     const key = queryKey()
     let cancelFn: jest.Mock = jest.fn()
@@ -4218,17 +4268,9 @@ describe('useQuery', () => {
     const key = queryKey()
     const states: UseQueryResult<string>[] = []
 
-    const queryFn = () => {
-      let cancelFn = jest.fn()
-
-      const promise = new Promise<string>((resolve, reject) => {
-        cancelFn = jest.fn(() => reject('Cancelled'))
-        sleep(50).then(() => resolve('OK'))
-      })
-
-      ;(promise as any).cancel = cancelFn
-
-      return promise
+    const queryFn = async () => {
+      await sleep(50)
+      return 'OK'
     }
 
     function Page() {
@@ -4611,6 +4653,82 @@ describe('useQuery', () => {
     consoleMock.mockRestore()
   })
 
+  it('should have no error in loading state when refetching after error occurred', async () => {
+    const consoleMock = mockConsoleError()
+    const key = queryKey()
+    const states: UseQueryResult<number>[] = []
+    const error = new Error('oops')
+
+    let count = 0
+
+    function Page() {
+      const state = useQuery(
+        key,
+        async () => {
+          await sleep(10)
+          if (count === 0) {
+            count++
+            throw error
+          }
+          return 5
+        },
+        {
+          retry: false,
+        }
+      )
+
+      states.push(state)
+
+      if (state.isLoading) {
+        return <div>status: loading</div>
+      }
+      if (state.error instanceof Error) {
+        return (
+          <div>
+            <div>error</div>
+            <button onClick={() => state.refetch()}>refetch</button>
+          </div>
+        )
+      }
+      return <div>data: {state.data}</div>
+    }
+
+    const rendered = renderWithClient(queryClient, <Page />)
+
+    await waitFor(() => rendered.getByText('error'))
+
+    fireEvent.click(rendered.getByRole('button', { name: 'refetch' }))
+    await waitFor(() => rendered.getByText('data: 5'))
+
+    await waitFor(() => expect(states.length).toBe(4))
+
+    expect(states[0]).toMatchObject({
+      status: 'loading',
+      data: undefined,
+      error: null,
+    })
+
+    expect(states[1]).toMatchObject({
+      status: 'error',
+      data: undefined,
+      error,
+    })
+
+    expect(states[2]).toMatchObject({
+      status: 'loading',
+      data: undefined,
+      error: null,
+    })
+
+    expect(states[3]).toMatchObject({
+      status: 'success',
+      data: 5,
+      error: null,
+    })
+
+    consoleMock.mockRestore()
+  })
+
   describe('networkMode online', () => {
     it('online queries should not start fetching if you are offline', async () => {
       const onlineMock = mockNavigatorOnLine(false)