Repository: tannerlinsley/react-query
Pull request #961 (2020-09-03 15:48:56)

feat: add isPreviousData and isFetchedAfterMount flags
This MR addresses a few things:

- Add `isPreviousData` flag to know if the data is from a previous query.
- Add `isFetchedAfterMount` flag to know if the data has been fetched after the component mounted. Can be used together with `forceFetchOnMount` to emulate Apollo's `network-only` fetch policy.
- Make sure `keepPreviousData` also works with disabled queries (fixes https://github.com/tannerlinsley/react-query/issues/959 and https://github.com/tannerlinsley/react-query/discussions/956). This does break a `usePagination` test, but I failed to see the logic behind the current behaviour and the end result can be replicated by toggling `keepPreviousData`. This can be seen in the test.


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



Patch:
diff --git a/docs/src/pages/docs/api.md b/docs/src/pages/docs/api.md
index 40e17818a..5bad26f35 100644
--- a/docs/src/pages/docs/api.md
+++ b/docs/src/pages/docs/api.md
@@ -12,9 +12,11 @@ const {
   error,
   failureCount,
   isError,
+  isFetchedAfterMount,
   isFetching,
   isIdle,
   isLoading,
+  isPreviousData,
   isStale,
   isSuccess,
   refetch,
@@ -100,8 +102,7 @@ const queryInfo = useQuery({
   - Set this to `true` or `false` to enable/disable automatic refetching on reconnect for this query.
 - `notifyOnStatusChange: Boolean`
   - Optional
-  - Whether a change to the query status should re-render a component.
-  - If set to `false`, the component will only re-render when the actual `data` or `error` changes.
+  - Set this to `false` to only re-render when there are changes to `data` or `error`.
   - Defaults to `true`.
 - `onSuccess: Function(data) => data`
   - Optional
@@ -170,6 +171,11 @@ const queryInfo = useQuery({
   - The error object for the query, if an error was thrown.
 - `isStale: Boolean`
   - Will be `true` if the cache data is stale.
+- `isPreviousData: Boolean`
+  - Will be `true` when `keepPreviousData` is set and data from the previous query is returned.
+- `isFetchedAfterMount: Boolean`
+  - Will be `true` if the query has been fetched after the component mounted.
+  - This property can be used to not show any previously cached data.
 - `isFetching: Boolean`
   - Defaults to `true` so long as `manual` is set to `false`
   - Will be `true` if the query is currently fetching, including background fetching.
diff --git a/src/core/config.ts b/src/core/config.ts
index 801a2cb26..3fe384173 100644
--- a/src/core/config.ts
+++ b/src/core/config.ts
@@ -51,16 +51,16 @@ export const DEFAULT_STALE_TIME = 0
 export const DEFAULT_CACHE_TIME = 5 * 60 * 1000
 export const DEFAULT_CONFIG: ReactQueryConfig = {
   queries: {
-    queryKeySerializerFn: defaultQueryKeySerializerFn,
+    cacheTime: DEFAULT_CACHE_TIME,
     enabled: true,
+    notifyOnStatusChange: true,
+    queryKeySerializerFn: defaultQueryKeySerializerFn,
+    refetchOnMount: true,
+    refetchOnReconnect: true,
+    refetchOnWindowFocus: true,
     retry: 3,
     retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
     staleTime: DEFAULT_STALE_TIME,
-    cacheTime: DEFAULT_CACHE_TIME,
-    refetchOnWindowFocus: true,
-    refetchOnReconnect: true,
-    refetchOnMount: true,
-    notifyOnStatusChange: true,
     structuralSharing: true,
   },
 }
diff --git a/src/core/query.ts b/src/core/query.ts
index 49b1a9d14..00c0bc202 100644
--- a/src/core/query.ts
+++ b/src/core/query.ts
@@ -9,6 +9,7 @@ import {
   isDocumentVisible,
   isOnline,
   isServer,
+  noop,
   replaceEqualDeep,
   sleep,
 } from './utils'
@@ -38,6 +39,7 @@ export interface QueryState<TResult, TError> {
   data?: TResult
   error: TError | null
   failureCount: number
+  fetchedCount: number
   isError: boolean
   isFetched: boolean
   isFetching: boolean
@@ -229,7 +231,7 @@ export class Query<TResult, TError> {
     )
   }
 
-  async onWindowFocus(): Promise<void> {
+  onWindowFocus(): void {
     if (
       this.observers.some(
         observer =>
@@ -238,17 +240,13 @@ export class Query<TResult, TError> {
           observer.config.refetchOnWindowFocus
       )
     ) {
-      try {
-        await this.fetch()
-      } catch {
-        // ignore
-      }
+      this.fetch().catch(noop)
     }
 
     this.continue()
   }
 
-  async onOnline(): Promise<void> {
+  onOnline(): void {
     if (
       this.observers.some(
         observer =>
@@ -257,11 +255,7 @@ export class Query<TResult, TError> {
           observer.config.refetchOnReconnect
       )
     ) {
-      try {
-        await this.fetch()
-      } catch {
-        // ignore
-      }
+      this.fetch().catch(noop)
     }
 
     this.continue()
@@ -612,6 +606,7 @@ function getDefaultState<TResult, TError>(
     isFetching: initialStatus === QueryStatus.Loading,
     isFetchingMore: false,
     failureCount: 0,
+    fetchedCount: 0,
     data: initialData,
     updatedAt: Date.now(),
     canFetchMore: hasMorePages(config, initialData),
@@ -646,6 +641,7 @@ export function queryReducer<TResult, TError>(
         ...getStatusProps(QueryStatus.Success),
         data: action.data,
         error: null,
+        fetchedCount: state.fetchedCount + 1,
         isFetched: true,
         isFetching: false,
         isFetchingMore: false,
@@ -658,6 +654,7 @@ export function queryReducer<TResult, TError>(
         ...state,
         ...getStatusProps(QueryStatus.Error),
         error: action.error,
+        fetchedCount: state.fetchedCount + 1,
         isFetched: true,
         isFetching: false,
         isFetchingMore: false,
diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts
index ce9fe72c3..fc38b3160 100644
--- a/src/core/queryObserver.ts
+++ b/src/core/queryObserver.ts
@@ -15,6 +15,7 @@ export class QueryObserver<TResult, TError> {
   private currentResult!: QueryResult<TResult, TError>
   private previousQueryResult?: QueryResult<TResult, TError>
   private updateListener?: UpdateListener<TResult, TError>
+  private initialFetchedCount: number
   private staleTimeoutId?: number
   private refetchIntervalId?: number
   private started?: boolean
@@ -22,6 +23,7 @@ export class QueryObserver<TResult, TError> {
   constructor(config: QueryObserverConfig<TResult, TError>) {
     this.config = config
     this.queryCache = config.queryCache!
+    this.initialFetchedCount = 0
 
     // Bind exposed methods
     this.clear = this.clear.bind(this)
@@ -100,6 +102,10 @@ export class QueryObserver<TResult, TError> {
     return this.currentResult.isStale
   }
 
+  getCurrentQuery(): Query<TResult, TError> {
+    return this.currentQuery
+  }
+
   getCurrentResult(): QueryResult<TResult, TError> {
     return this.currentResult
   }
@@ -224,16 +230,18 @@ export class QueryObserver<TResult, TError> {
     const { currentQuery, currentResult, previousQueryResult, config } = this
     const { state } = currentQuery
     let { data, status, updatedAt } = state
+    let isPreviousData = false
 
     // Keep previous data if needed
     if (
       config.keepPreviousData &&
-      state.isLoading &&
+      (state.isIdle || state.isLoading) &&
       previousQueryResult?.isSuccess
     ) {
       data = previousQueryResult.data
       updatedAt = previousQueryResult.updatedAt
       status = previousQueryResult.status
+      isPreviousData = true
     }
 
     let isStale = false
@@ -261,10 +269,11 @@ export class QueryObserver<TResult, TError> {
       failureCount: state.failureCount,
       fetchMore: this.fetchMore,
       isFetched: state.isFetched,
+      isFetchedAfterMount: state.fetchedCount > this.initialFetchedCount,
       isFetching: state.isFetching,
       isFetchingMore: state.isFetchingMore,
+      isPreviousData,
       isStale,
-      query: currentQuery,
       refetch: this.refetch,
       updatedAt,
     }
@@ -288,6 +297,7 @@ export class QueryObserver<TResult, TError> {
 
     this.previousQueryResult = this.currentResult
     this.currentQuery = newQuery
+    this.initialFetchedCount = newQuery.state.fetchedCount
     this.updateResult()
 
     if (this.started) {
diff --git a/src/core/types.ts b/src/core/types.ts
index 1a8f16a9b..9e363e8bb 100644
--- a/src/core/types.ts
+++ b/src/core/types.ts
@@ -1,4 +1,4 @@
-import type { Query, FetchMoreOptions, RefetchOptions } from './query'
+import type { FetchMoreOptions, RefetchOptions } from './query'
 import type { QueryCache } from './queryCache'
 
 export type QueryKey =
@@ -34,11 +34,6 @@ export type QueryKeySerializerFunction = (
 ) => [string, QueryKey[]]
 
 export interface BaseQueryConfig<TResult, TError = unknown, TData = TResult> {
-  /**
-   * Set this to `false` to disable automatic refetching when the query mounts or changes query keys.
-   * To refetch the query, use the `refetch` method returned from the `useQuery` instance.
-   */
-  enabled?: boolean | unknown
   /**
    * If `false`, failed queries will not retry by default.
    * If `true`, failed queries will retry infinitely., failureCount: num
@@ -180,13 +175,14 @@ export interface QueryResultBase<TResult, TError = unknown> {
   ) => Promise<TResult | undefined>
   isError: boolean
   isFetched: boolean
+  isFetchedAfterMount: boolean
   isFetching: boolean
   isFetchingMore?: IsFetchingMoreValue
   isIdle: boolean
   isLoading: boolean
   isStale: boolean
   isSuccess: boolean
-  query: Query<TResult, TError>
+  isPreviousData: boolean
   refetch: (options?: RefetchOptions) => Promise<TResult | undefined>
   status: QueryStatus
   updatedAt: number
diff --git a/src/core/utils.ts b/src/core/utils.ts
index 2d8cb6e24..25d0c9ab5 100644
--- a/src/core/utils.ts
+++ b/src/core/utils.ts
@@ -29,7 +29,7 @@ export const uid = () => _uid++
 
 export const isServer = typeof window === 'undefined'
 
-function noop(): void {
+export function noop(): void {
   return void 0
 }
 
diff --git a/src/react/useBaseQuery.ts b/src/react/useBaseQuery.ts
index f5c76f50d..43023f266 100644
--- a/src/react/useBaseQuery.ts
+++ b/src/react/useBaseQuery.ts
@@ -37,7 +37,9 @@ export function useBaseQuery<TResult, TError>(
 
   // Handle suspense
   if (config.suspense || config.useErrorBoundary) {
-    if (result.isError && result.query.state.throwInErrorBoundary) {
+    const query = observer.getCurrentQuery()
+
+    if (result.isError && query.state.throwInErrorBoundary) {
       throw result.error
     }
 
diff --git a/src/react/usePaginatedQuery.ts b/src/react/usePaginatedQuery.ts
index a144bb2ab..4eefce3e9 100644
--- a/src/react/usePaginatedQuery.ts
+++ b/src/react/usePaginatedQuery.ts
@@ -58,13 +58,12 @@ export function usePaginatedQuery<TResult, TError>(
 ): PaginatedQueryResult<TResult, TError> {
   const config = getQueryArgs<TResult, TError>(args)[1]
   const result = useBaseQuery<TResult, TError>({
-    ...config,
     keepPreviousData: true,
+    ...config,
   })
   return {
     ...result,
     resolvedData: result.data,
-    latestData:
-      result.query.state.data === result.data ? result.data : undefined,
+    latestData: result.isPreviousData ? undefined : result.data,
   }
 }


Test patch:
diff --git a/src/react/tests/useInfiniteQuery.test.tsx b/src/react/tests/useInfiniteQuery.test.tsx
index 86e0d0677..f991efb53 100644
--- a/src/react/tests/useInfiniteQuery.test.tsx
+++ b/src/react/tests/useInfiniteQuery.test.tsx
@@ -68,13 +68,14 @@ describe('useInfiniteQuery', () => {
       fetchMore: expect.any(Function),
       isError: false,
       isFetched: false,
+      isFetchedAfterMount: false,
       isFetching: true,
       isFetchingMore: false,
       isIdle: false,
       isLoading: true,
+      isPreviousData: false,
       isStale: true,
       isSuccess: false,
-      query: expect.any(Object),
       refetch: expect.any(Function),
       status: 'loading',
       updatedAt: expect.any(Number),
@@ -96,12 +97,13 @@ describe('useInfiniteQuery', () => {
       isFetchingMore: false,
       isError: false,
       isFetched: true,
+      isFetchedAfterMount: true,
       isFetching: false,
       isIdle: false,
       isLoading: false,
+      isPreviousData: false,
       isStale: true,
       isSuccess: true,
-      query: expect.any(Object),
       refetch: expect.any(Function),
       status: 'success',
       updatedAt: expect.any(Number),
@@ -152,36 +154,42 @@ describe('useInfiniteQuery', () => {
       isFetching: true,
       isFetchingMore: false,
       isSuccess: false,
+      isPreviousData: false,
     })
     expect(states[1]).toMatchObject({
       data: ['0-desc'],
       isFetching: false,
       isFetchingMore: false,
       isSuccess: true,
+      isPreviousData: false,
     })
     expect(states[2]).toMatchObject({
       data: ['0-desc'],
       isFetching: true,
       isFetchingMore: 'next',
       isSuccess: true,
+      isPreviousData: false,
     })
     expect(states[3]).toMatchObject({
       data: ['0-desc', '1-desc'],
       isFetching: false,
       isFetchingMore: false,
       isSuccess: true,
+      isPreviousData: false,
     })
     expect(states[4]).toMatchObject({
       data: ['0-desc', '1-desc'],
       isFetching: true,
       isFetchingMore: false,
       isSuccess: true,
+      isPreviousData: true,
     })
     expect(states[5]).toMatchObject({
       data: ['0-asc'],
       isFetching: false,
       isFetchingMore: false,
       isSuccess: true,
+      isPreviousData: false,
     })
   })
 
diff --git a/src/react/tests/usePaginatedQuery.test.tsx b/src/react/tests/usePaginatedQuery.test.tsx
index 59f411997..d54465216 100644
--- a/src/react/tests/usePaginatedQuery.test.tsx
+++ b/src/react/tests/usePaginatedQuery.test.tsx
@@ -41,13 +41,14 @@ describe('usePaginatedQuery', () => {
       fetchMore: expect.any(Function),
       isError: false,
       isFetched: false,
+      isFetchedAfterMount: false,
       isFetching: true,
       isFetchingMore: false,
       isIdle: false,
       isLoading: true,
+      isPreviousData: false,
       isStale: true,
       isSuccess: false,
-      query: expect.any(Object),
       latestData: undefined,
       resolvedData: undefined,
       refetch: expect.any(Function),
@@ -64,13 +65,14 @@ describe('usePaginatedQuery', () => {
       fetchMore: expect.any(Function),
       isError: false,
       isFetched: true,
+      isFetchedAfterMount: true,
       isFetching: false,
       isFetchingMore: false,
       isIdle: false,
       isLoading: false,
+      isPreviousData: false,
       isStale: true,
       isSuccess: true,
-      query: expect.any(Object),
       latestData: 1,
       resolvedData: 1,
       refetch: expect.any(Function),
@@ -208,6 +210,7 @@ describe('usePaginatedQuery', () => {
         },
         {
           enabled: searchTerm,
+          keepPreviousData: page !== 1,
         }
       )
 
diff --git a/src/react/tests/useQuery.test.tsx b/src/react/tests/useQuery.test.tsx
index cc1882e7e..8e1736faa 100644
--- a/src/react/tests/useQuery.test.tsx
+++ b/src/react/tests/useQuery.test.tsx
@@ -130,13 +130,14 @@ describe('useQuery', () => {
       fetchMore: expect.any(Function),
       isError: false,
       isFetched: false,
+      isFetchedAfterMount: false,
       isFetching: true,
       isFetchingMore: false,
       isIdle: false,
       isLoading: true,
+      isPreviousData: false,
       isStale: true,
       isSuccess: false,
-      query: expect.any(Object),
       refetch: expect.any(Function),
       status: 'loading',
       updatedAt: expect.any(Number),
@@ -151,13 +152,14 @@ describe('useQuery', () => {
       fetchMore: expect.any(Function),
       isError: false,
       isFetched: true,
+      isFetchedAfterMount: true,
       isFetching: false,
       isFetchingMore: false,
       isIdle: false,
       isLoading: false,
+      isPreviousData: false,
       isStale: true,
       isSuccess: true,
-      query: expect.any(Object),
       refetch: expect.any(Function),
       status: 'success',
       updatedAt: expect.any(Number),
@@ -202,13 +204,14 @@ describe('useQuery', () => {
       fetchMore: expect.any(Function),
       isError: false,
       isFetched: false,
+      isFetchedAfterMount: false,
       isFetching: true,
       isFetchingMore: false,
       isIdle: false,
       isLoading: true,
+      isPreviousData: false,
       isStale: true,
       isSuccess: false,
-      query: expect.any(Object),
       refetch: expect.any(Function),
       status: 'loading',
       updatedAt: expect.any(Number),
@@ -223,13 +226,14 @@ describe('useQuery', () => {
       fetchMore: expect.any(Function),
       isError: false,
       isFetched: false,
+      isFetchedAfterMount: false,
       isFetching: true,
       isFetchingMore: false,
       isIdle: false,
       isLoading: true,
+      isPreviousData: false,
       isStale: true,
       isSuccess: false,
-      query: expect.any(Object),
       refetch: expect.any(Function),
       status: 'loading',
       updatedAt: expect.any(Number),
@@ -244,13 +248,14 @@ describe('useQuery', () => {
       fetchMore: expect.any(Function),
       isError: true,
       isFetched: true,
+      isFetchedAfterMount: true,
       isFetching: false,
       isFetchingMore: false,
       isIdle: false,
       isLoading: false,
+      isPreviousData: false,
       isStale: true,
       isSuccess: false,
-      query: expect.any(Object),
       refetch: expect.any(Function),
       status: 'error',
       updatedAt: expect.any(Number),
@@ -259,6 +264,39 @@ describe('useQuery', () => {
     consoleMock.mockRestore()
   })
 
+  it('should set isFetchedAfterMount to true after a query has been fetched', async () => {
+    const key = queryKey()
+    const states: QueryResult<string>[] = []
+
+    await queryCache.prefetchQuery(key, () => 'prefetched')
+
+    function Page() {
+      const state = useQuery(key, () => 'data')
+      states.push(state)
+      return null
+    }
+
+    render(<Page />)
+
+    await waitFor(() => expect(states.length).toBe(3))
+
+    expect(states[0]).toMatchObject({
+      data: 'prefetched',
+      isFetched: true,
+      isFetchedAfterMount: false,
+    })
+    expect(states[1]).toMatchObject({
+      data: 'prefetched',
+      isFetched: true,
+      isFetchedAfterMount: false,
+    })
+    expect(states[2]).toMatchObject({
+      data: 'data',
+      isFetched: true,
+      isFetchedAfterMount: true,
+    })
+  })
+
   // https://github.com/tannerlinsley/react-query/issues/896
   it('should fetch data in Strict mode when refetchOnMount is false', async () => {
     const key = queryKey()
@@ -442,21 +480,108 @@ describe('useQuery', () => {
       data: undefined,
       isFetching: true,
       isSuccess: false,
+      isPreviousData: false,
     })
     expect(states[1]).toMatchObject({
       data: 0,
       isFetching: false,
       isSuccess: true,
+      isPreviousData: false,
     })
     expect(states[2]).toMatchObject({
       data: 0,
       isFetching: true,
       isSuccess: true,
+      isPreviousData: true,
     })
     expect(states[3]).toMatchObject({
       data: 1,
       isFetching: false,
       isSuccess: true,
+      isPreviousData: false,
+    })
+  })
+
+  it('should keep the previous data on disabled query when keepPreviousData is set', async () => {
+    const key = queryKey()
+    const states: QueryResult<number>[] = []
+
+    function Page() {
+      const [count, setCount] = React.useState(0)
+
+      const state = useQuery(
+        [key, count],
+        async () => {
+          await sleep(10)
+          return count
+        },
+        { enabled: false, keepPreviousData: true }
+      )
+
+      states.push(state)
+
+      const { refetch } = state
+
+      React.useEffect(() => {
+        refetch()
+
+        setTimeout(() => {
+          setCount(1)
+        }, 20)
+
+        setTimeout(() => {
+          refetch()
+        }, 30)
+      }, [refetch])
+
+      return null
+    }
+
+    render(<Page />)
+
+    await waitFor(() => expect(states.length).toBe(6))
+
+    // Disabled query
+    expect(states[0]).toMatchObject({
+      data: undefined,
+      isFetching: false,
+      isSuccess: false,
+      isPreviousData: false,
+    })
+    // Fetching query
+    expect(states[1]).toMatchObject({
+      data: undefined,
+      isFetching: true,
+      isSuccess: false,
+      isPreviousData: false,
+    })
+    // Fetched query
+    expect(states[2]).toMatchObject({
+      data: 0,
+      isFetching: false,
+      isSuccess: true,
+      isPreviousData: false,
+    })
+    // Switched query key
+    expect(states[3]).toMatchObject({
+      data: 0,
+      isFetching: false,
+      isSuccess: true,
+      isPreviousData: true,
+    })
+    // Fetching new query
+    expect(states[4]).toMatchObject({
+      data: 0,
+      isFetching: true,
+      isSuccess: true,
+      isPreviousData: true,
+    })
+    // Fetched new query
+    expect(states[5]).toMatchObject({
+      data: 1,
+      isFetching: false,
+      isSuccess: true,
+      isPreviousData: false,
     })
   })