Repository: tannerlinsley/react-query
Pull request #2557 (2021-08-10 12:00:48)

feat(infiniteQuery): add possibility to decide which pages should be refetched
refs #1866 

missing:

- [x] finalize api
- [x] tests
- [x] docs

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


codesandbox[bot]: This pull request is automatically built and testable in [CodeSandbox](https://codesandbox.io).

  To see build info of the built libraries, click [here](https://ci.codesandbox.io/status/tannerlinsley/react-query/pr/2557/builds/158154) or the icon next to each commit SHA.

Latest deployment of this branch, based on commit 3fb78cb102b050a0f65564af1d42d9661c73bdb0:

|Sandbox| Source |
|--|--|
|[tannerlinsley/react-query: basic](https://codesandbox.io/s/tannerlinsley-react-query-basic-ptjwz)| Configuration |
|[tannerlinsley/react-query: basic-typescript](https://codesandbox.io/s/tannerlinsley-react-query-basic-typescript-zthqu)| Configuration |


Patch:
diff --git a/docs/src/pages/guides/infinite-queries.md b/docs/src/pages/guides/infinite-queries.md
index 7981d3c20..50d300bab 100644
--- a/docs/src/pages/guides/infinite-queries.md
+++ b/docs/src/pages/guides/infinite-queries.md
@@ -95,6 +95,27 @@ function Projects() {
 
 When an infinite query becomes `stale` and needs to be refetched, each group is fetched `sequentially`, starting from the first one. This ensures that even if the underlying data is mutated, we're not using stale cursors and potentially getting duplicates or skipping records. If an infinite query's results are ever removed from the queryCache, the pagination restarts at the initial state with only the initial group being requested.
 
+### refetchPage
+
+If you only want to actively refetch a subset of all pages, you can pass the `refetchPage` function to `refetch` returned from `useInfiniteQuery`.
+
+```js
+const { refetch } = useInfiniteQuery('projects', fetchProjects, {
+  getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
+})
+
+// only refetch the first page
+refetch({ refetchPage: (page, index) => index === 0 })
+```
+
+You can also pass this function as part of the 2nd argument (`queryFilters`) to [queryClient.refetchQueries](/reference/QueryClient#queryclientrefetchqueries), [queryClient.invalidateQueries](/reference/QueryClient#queryclientinvalidatequeries) or [queryClient.resetQueries](/reference/QueryClient#queryclientresetqueries).
+
+**Signature**
+
+- `refetchPage: (page: TData, index: number, allPages: TData[]) => boolean`
+
+The function is executed for each page, and only pages where this function returns `true` will be refetched.
+
 ## What if I need to pass custom information to my query function?
 
 By default, the variable returned from `getNextPageParam` will be supplied to the query function, but in some cases, you may want to override this. You can pass custom variables to the `fetchNextPage` function which will override the default variable like so:
diff --git a/docs/src/pages/reference/QueriesObserver.md b/docs/src/pages/reference/QueriesObserver.md
index 8ea31db44..1d7a1af96 100644
--- a/docs/src/pages/reference/QueriesObserver.md
+++ b/docs/src/pages/reference/QueriesObserver.md
@@ -21,4 +21,4 @@ const unsubscribe = observer.subscribe(result => {
 
 **Options**
 
-The options for the `QueriesObserver` are exactly the same as those of [`useQueries`](#usequeries).
+The options for the `QueriesObserver` are exactly the same as those of [`useQueries`](/reference/useQueries).
diff --git a/docs/src/pages/reference/QueryClient.md b/docs/src/pages/reference/QueryClient.md
index cb2e2175c..f5ed07dd7 100644
--- a/docs/src/pages/reference/QueryClient.md
+++ b/docs/src/pages/reference/QueryClient.md
@@ -194,7 +194,7 @@ const data = queryClient.getQueriesData(queryKey | filters)
 
 - `[queryKey:QueryKey, data:TData | unknown][]`
   - An array of tuples for the matched query keys, or `[]` if there are no matches. The tuples are the query key and its associated data.
-  
+
 **Caveats**
 
 Because the returned data in each tuple can be of varying structures (i.e. using a filter to return "active" queries can return different data types), the `TData` generic defaults to `unknown`. If you provide a more specific type to `TData` it is assumed that you are certain each tuple's data entry is all the same type.
@@ -287,6 +287,9 @@ await queryClient.invalidateQueries('posts', {
   - `refetchInactive: Boolean`
     - Defaults to `false`
     - When set to `true`, queries that match the refetch predicate and are not being rendered via `useQuery` and friends will be both marked as invalid and also refetched in the background
+  - `refetchPage: (page: TData, index: number, allPages: TData[]) => boolean`
+    - Only for [Infinite Queries](../guides/infinite-queries#refetchpage)
+    - Use this function to specify which pages should be refetched
 - `refetchOptions?: RefetchOptions`:
   - `throwOnError?: boolean`
     - When set to `true`, this method will throw if any of the query refetch tasks fail.
@@ -315,6 +318,9 @@ await queryClient.refetchQueries(['posts', 1], { active: true, exact: true })
 
 - `queryKey?: QueryKey`: [Query Keys](../guides/query-keys)
 - `filters?: QueryFilters`: [Query Filters](../guides/filters#query-filters)
+  - `refetchPage: (page: TData, index: number, allPages: TData[]) => boolean`
+    - Only for [Infinite Queries](../guides/infinite-queries#refetchpage)
+    - Use this function to specify which pages should be refetched
 - `refetchOptions?: RefetchOptions`:
   - `throwOnError?: boolean`
     - When set to `true`, this method will throw if any of the query refetch tasks fail.
@@ -378,6 +384,9 @@ queryClient.resetQueries(queryKey, { exact: true })
 
 - `queryKey?: QueryKey`: [Query Keys](../guides/query-keys)
 - `filters?: QueryFilters`: [Query Filters](../guides/filters#query-filters)
+  - `refetchPage: (page: TData, index: number, allPages: TData[]) => boolean`
+    - Only for [Infinite Queries](../guides/infinite-queries#refetchpage)
+    - Use this function to specify which pages should be refetched
 - `resetOptions?: ResetOptions`:
   - `throwOnError?: boolean`
     - When set to `true`, this method will throw if any of the query refetch tasks fail.
diff --git a/src/core/infiniteQueryBehavior.ts b/src/core/infiniteQueryBehavior.ts
index beed03d62..b6a953809 100644
--- a/src/core/infiniteQueryBehavior.ts
+++ b/src/core/infiniteQueryBehavior.ts
@@ -1,6 +1,11 @@
 import type { QueryBehavior } from './query'
 import { isCancelable } from './retryer'
-import type { InfiniteData, QueryFunctionContext, QueryOptions } from './types'
+import type {
+  InfiniteData,
+  QueryFunctionContext,
+  QueryOptions,
+  RefetchQueryFilters,
+} from './types'
 
 export function infiniteQueryBehavior<
   TQueryFnData,
@@ -10,6 +15,8 @@ export function infiniteQueryBehavior<
   return {
     onFetch: context => {
       context.fetchFn = () => {
+        const refetchPage: RefetchQueryFilters['refetchPage'] | undefined =
+          context.fetchOptions?.meta?.refetchPage
         const fetchMore = context.fetchOptions?.meta?.fetchMore
         const pageParam = fetchMore?.pageParam
         const isFetchingNextPage = fetchMore?.direction === 'forward'
@@ -23,6 +30,18 @@ export function infiniteQueryBehavior<
         const queryFn =
           context.options.queryFn || (() => Promise.reject('Missing queryFn'))
 
+        const buildNewPages = (
+          pages: unknown[],
+          param: unknown,
+          page: unknown,
+          previous?: boolean
+        ) => {
+          newPageParams = previous
+            ? [param, ...newPageParams]
+            : [...newPageParams, param]
+          return previous ? [page, ...pages] : [...pages, page]
+        }
+
         // Create function to fetch a page
         const fetchPage = (
           pages: unknown[],
@@ -45,12 +64,9 @@ export function infiniteQueryBehavior<
 
           const queryFnResult = queryFn(queryFnContext)
 
-          const promise = Promise.resolve(queryFnResult).then(page => {
-            newPageParams = previous
-              ? [param, ...newPageParams]
-              : [...newPageParams, param]
-            return previous ? [page, ...pages] : [...pages, page]
-          })
+          const promise = Promise.resolve(queryFnResult).then(page =>
+            buildNewPages(pages, param, page, previous)
+          )
 
           if (isCancelable(queryFnResult)) {
             const promiseAsAny = promise as any
@@ -91,16 +107,33 @@ export function infiniteQueryBehavior<
 
           const manual = typeof context.options.getNextPageParam === 'undefined'
 
+          const shouldFetchFirstPage =
+            refetchPage && oldPages[0]
+              ? refetchPage(oldPages[0], 0, oldPages)
+              : true
+
           // Fetch first page
-          promise = fetchPage([], manual, oldPageParams[0])
+          promise = shouldFetchFirstPage
+            ? fetchPage([], manual, oldPageParams[0])
+            : Promise.resolve(buildNewPages([], oldPageParams[0], oldPages[0]))
 
           // Fetch remaining pages
           for (let i = 1; i < oldPages.length; i++) {
             promise = promise.then(pages => {
-              const param = manual
-                ? oldPageParams[i]
-                : getNextPageParam(context.options, pages)
-              return fetchPage(pages, manual, param)
+              const shouldFetchNextPage =
+                refetchPage && oldPages[i]
+                  ? refetchPage(oldPages[i], i, oldPages)
+                  : true
+
+              if (shouldFetchNextPage) {
+                const param = manual
+                  ? oldPageParams[i]
+                  : getNextPageParam(context.options, pages)
+                return fetchPage(pages, manual, param)
+              }
+              return Promise.resolve(
+                buildNewPages(pages, oldPageParams[i], oldPages[i])
+              )
             })
           }
         }
diff --git a/src/core/queryClient.ts b/src/core/queryClient.ts
index 6724cb5a6..2213f4521 100644
--- a/src/core/queryClient.ts
+++ b/src/core/queryClient.ts
@@ -24,7 +24,9 @@ import type {
   QueryObserverOptions,
   QueryOptions,
   RefetchOptions,
+  RefetchQueryFilters,
   ResetOptions,
+  ResetQueryFilters,
 } from './types'
 import type { QueryState, SetDataOptions } from './query'
 import { QueryCache } from './queryCache'
@@ -182,21 +184,24 @@ export class QueryClient {
     })
   }
 
-  resetQueries(filters?: QueryFilters, options?: ResetOptions): Promise<void>
+  resetQueries(
+    filters?: ResetQueryFilters,
+    options?: ResetOptions
+  ): Promise<void>
   resetQueries(
     queryKey?: QueryKey,
-    filters?: QueryFilters,
+    filters?: ResetQueryFilters,
     options?: ResetOptions
   ): Promise<void>
   resetQueries(
-    arg1?: QueryKey | QueryFilters,
-    arg2?: QueryFilters | ResetOptions,
+    arg1?: QueryKey | ResetQueryFilters,
+    arg2?: ResetQueryFilters | ResetOptions,
     arg3?: ResetOptions
   ): Promise<void> {
     const [filters, options] = parseFilterArgs(arg1, arg2, arg3)
     const queryCache = this.queryCache
 
-    const refetchFilters: QueryFilters = {
+    const refetchFilters: RefetchQueryFilters = {
       ...filters,
       active: true,
     }
@@ -249,7 +254,7 @@ export class QueryClient {
   ): Promise<void> {
     const [filters, options] = parseFilterArgs(arg1, arg2, arg3)
 
-    const refetchFilters: QueryFilters = {
+    const refetchFilters: RefetchQueryFilters = {
       ...filters,
       // if filters.refetchActive is not provided and filters.active is explicitly false,
       // e.g. invalidateQueries({ active: false }), we don't want to refetch active queries
@@ -266,23 +271,27 @@ export class QueryClient {
   }
 
   refetchQueries(
-    filters?: QueryFilters,
+    filters?: RefetchQueryFilters,
     options?: RefetchOptions
   ): Promise<void>
   refetchQueries(
     queryKey?: QueryKey,
-    filters?: QueryFilters,
+    filters?: RefetchQueryFilters,
     options?: RefetchOptions
   ): Promise<void>
   refetchQueries(
-    arg1?: QueryKey | QueryFilters,
-    arg2?: QueryFilters | RefetchOptions,
+    arg1?: QueryKey | RefetchQueryFilters,
+    arg2?: RefetchQueryFilters | RefetchOptions,
     arg3?: RefetchOptions
   ): Promise<void> {
     const [filters, options] = parseFilterArgs(arg1, arg2, arg3)
 
     const promises = notifyManager.batch(() =>
-      this.queryCache.findAll(filters).map(query => query.fetch())
+      this.queryCache.findAll(filters).map(query =>
+        query.fetch(undefined, {
+          meta: { refetchPage: filters?.refetchPage },
+        })
+      )
     )
 
     let promise = Promise.all(promises).then(noop)
diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts
index cfff92f7a..3827b2cea 100644
--- a/src/core/queryObserver.ts
+++ b/src/core/queryObserver.ts
@@ -1,3 +1,4 @@
+import { RefetchQueryFilters } from './types'
 import {
   isServer,
   isValidTimeout,
@@ -275,9 +276,12 @@ export class QueryObserver<
   }
 
   refetch(
-    options?: RefetchOptions
+    options?: RefetchOptions & RefetchQueryFilters<TData>
   ): Promise<QueryObserverResult<TData, TError>> {
-    return this.fetch(options)
+    return this.fetch({
+      ...options,
+      meta: { refetchPage: options?.refetchPage },
+    })
   }
 
   fetchOptimistic(
diff --git a/src/core/types.ts b/src/core/types.ts
index 8d1b94c9a..106ca2787 100644
--- a/src/core/types.ts
+++ b/src/core/types.ts
@@ -236,15 +236,33 @@ export interface ResultOptions {
   throwOnError?: boolean
 }
 
+export interface RefetchPageFilters<TQueryFnData = unknown> {
+  refetchPage?: (
+    lastPage: TQueryFnData,
+    index: number,
+    allPages: TQueryFnData[]
+  ) => boolean
+}
+
 export interface RefetchOptions extends ResultOptions {
   cancelRefetch?: boolean
 }
 
-export interface InvalidateQueryFilters extends QueryFilters {
+export interface InvalidateQueryFilters<TQueryFnData = unknown>
+  extends QueryFilters,
+    RefetchPageFilters<TQueryFnData> {
   refetchActive?: boolean
   refetchInactive?: boolean
 }
 
+export interface RefetchQueryFilters<TQueryFnData = unknown>
+  extends QueryFilters,
+    RefetchPageFilters<TQueryFnData> {}
+
+export interface ResetQueryFilters<TQueryFnData = unknown>
+  extends QueryFilters,
+    RefetchPageFilters<TQueryFnData> {}
+
 export interface InvalidateOptions {
   throwOnError?: boolean
 }
@@ -282,7 +300,7 @@ export interface QueryObserverBaseResult<TData = unknown, TError = unknown> {
   isStale: boolean
   isSuccess: boolean
   refetch: (
-    options?: RefetchOptions
+    options?: RefetchOptions & RefetchQueryFilters<TData>
   ) => Promise<QueryObserverResult<TData, TError>>
   remove: () => void
   status: QueryStatus


Test patch:
diff --git a/src/core/tests/queryClient.test.tsx b/src/core/tests/queryClient.test.tsx
index 75646a4d4..3f88c7d14 100644
--- a/src/core/tests/queryClient.test.tsx
+++ b/src/core/tests/queryClient.test.tsx
@@ -1,5 +1,11 @@
 import { sleep, queryKey, mockConsoleError } from '../../react/tests/utils'
-import { QueryCache, QueryClient, QueryFunction, QueryObserver } from '../..'
+import {
+  InfiniteQueryObserver,
+  QueryCache,
+  QueryClient,
+  QueryFunction,
+  QueryObserver,
+} from '../..'
 
 describe('queryClient', () => {
   let queryClient: QueryClient
@@ -918,4 +924,96 @@ describe('queryClient', () => {
       expect(queryFn2).toHaveBeenCalledTimes(0)
     })
   })
+
+  describe('refetch only certain pages of an infinite query', () => {
+    test('refetchQueries', async () => {
+      const key = queryKey()
+      let multiplier = 1
+      const observer = new InfiniteQueryObserver<number>(queryClient, {
+        queryKey: key,
+        queryFn: ({ pageParam = 10 }) => Number(pageParam) * multiplier,
+        getNextPageParam: lastPage => lastPage + 1,
+      })
+
+      await observer.fetchNextPage()
+      await observer.fetchNextPage()
+
+      expect(queryClient.getQueryData(key)).toMatchObject({
+        pages: [10, 11],
+      })
+
+      multiplier = 2
+
+      await queryClient.refetchQueries({
+        queryKey: key,
+        refetchPage: (_, index) => index === 0,
+      })
+
+      expect(queryClient.getQueryData(key)).toMatchObject({
+        pages: [20, 11],
+      })
+    })
+    test('invalidateQueries', async () => {
+      const key = queryKey()
+      let multiplier = 1
+      const observer = new InfiniteQueryObserver<number>(queryClient, {
+        queryKey: key,
+        queryFn: ({ pageParam = 10 }) => Number(pageParam) * multiplier,
+        getNextPageParam: lastPage => lastPage + 1,
+      })
+
+      await observer.fetchNextPage()
+      await observer.fetchNextPage()
+
+      expect(queryClient.getQueryData(key)).toMatchObject({
+        pages: [10, 11],
+      })
+
+      multiplier = 2
+
+      await queryClient.invalidateQueries({
+        queryKey: key,
+        refetchInactive: true,
+        refetchPage: (page, _, allPages) => {
+          return page === allPages[0]
+        },
+      })
+
+      expect(queryClient.getQueryData(key)).toMatchObject({
+        pages: [20, 11],
+      })
+    })
+
+    test('resetQueries', async () => {
+      const key = queryKey()
+      let multiplier = 1
+      new InfiniteQueryObserver<number>(queryClient, {
+        queryKey: key,
+        queryFn: ({ pageParam = 10 }) => Number(pageParam) * multiplier,
+        getNextPageParam: lastPage => lastPage + 1,
+        initialData: () => ({
+          pages: [10, 11],
+          pageParams: [10, 11],
+        }),
+      })
+
+      expect(queryClient.getQueryData(key)).toMatchObject({
+        pages: [10, 11],
+      })
+
+      multiplier = 2
+
+      await queryClient.resetQueries({
+        queryKey: key,
+        inactive: true,
+        refetchPage: (page, _, allPages) => {
+          return page === allPages[0]
+        },
+      })
+
+      expect(queryClient.getQueryData(key)).toMatchObject({
+        pages: [20, 11],
+      })
+    })
+  })
 })
diff --git a/src/react/tests/useInfiniteQuery.test.tsx b/src/react/tests/useInfiniteQuery.test.tsx
index 81b9b5b3e..62170d584 100644
--- a/src/react/tests/useInfiniteQuery.test.tsx
+++ b/src/react/tests/useInfiniteQuery.test.tsx
@@ -594,6 +594,82 @@ describe('useInfiniteQuery', () => {
     })
   })
 
+  it('should be able to refetch only specific pages when refetchPages is provided', async () => {
+    const key = queryKey()
+    const states: UseInfiniteQueryResult<number>[] = []
+
+    function Page() {
+      const multiplier = React.useRef(1)
+      const state = useInfiniteQuery(
+        key,
+        ({ pageParam = 10 }) => Number(pageParam) * multiplier.current,
+        {
+          getNextPageParam: lastPage => lastPage + 1,
+        }
+      )
+
+      states.push(state)
+
+      const { fetchNextPage, refetch } = state
+
+      React.useEffect(() => {
+        setActTimeout(() => {
+          fetchNextPage()
+        }, 10)
+        setActTimeout(() => {
+          multiplier.current = 2
+          refetch({
+            refetchPage: (_, index) => index === 0,
+          })
+        }, 20)
+      }, [fetchNextPage, refetch])
+
+      return null
+    }
+
+    renderWithClient(queryClient, <Page />)
+
+    await sleep(50)
+
+    expect(states.length).toBe(6)
+    // Initial fetch
+    expect(states[0]).toMatchObject({
+      data: undefined,
+      isFetching: true,
+      isFetchingNextPage: false,
+    })
+    // Initial fetch done
+    expect(states[1]).toMatchObject({
+      data: { pages: [10] },
+      isFetching: false,
+      isFetchingNextPage: false,
+    })
+    // Fetch next page
+    expect(states[2]).toMatchObject({
+      data: { pages: [10] },
+      isFetching: true,
+      isFetchingNextPage: true,
+    })
+    // Fetch next page done
+    expect(states[3]).toMatchObject({
+      data: { pages: [10, 11] },
+      isFetching: false,
+      isFetchingNextPage: false,
+    })
+    // Refetch
+    expect(states[4]).toMatchObject({
+      data: { pages: [10, 11] },
+      isFetching: true,
+      isFetchingNextPage: false,
+    })
+    // Refetch done, only page one has been refetched and multiplied
+    expect(states[5]).toMatchObject({
+      data: { pages: [20, 11] },
+      isFetching: false,
+      isFetchingNextPage: false,
+    })
+  })
+
   it('should silently cancel any ongoing fetch when fetching more', async () => {
     const key = queryKey()
     const states: UseInfiniteQueryResult<number>[] = []