框架
版本

无限查询

渲染列表可以将“加载更多”数据添加到现有数据集或“无限滚动”,这也是一种非常常见的 UI 模式。 TanStack Query 支持 useQuery 的一个有用版本 useInfiniteQuery,用于查询这些类型的列表。

当使用 useInfiniteQuery 时,你会注意到一些不同之处

  • data 现在是一个包含无限查询数据的对象
  • data.pages 包含已获取页面的数组
  • data.pageParams 包含用于获取页面的页面参数数组
  • 现在可以使用 fetchNextPagefetchPreviousPage 函数(fetchNextPage 是必需的)
  • initialPageParam 选项现在可用(且必需)用于指定初始页面参数
  • 可以使用 getNextPageParamgetPreviousPageParam 选项来确定是否还有更多数据要加载以及获取数据的信息。此信息作为查询函数中的附加参数提供
  • 现在可以使用 hasNextPage 布尔值,如果 getNextPageParam 返回 nullundefined 以外的值,则为 true
  • 现在可以使用 hasPreviousPage 布尔值,如果 getPreviousPageParam 返回 nullundefined 以外的值,则为 true
  • 现在可以使用 isFetchingNextPageisFetchingPreviousPage 布尔值来区分后台刷新状态和加载更多状态

注意:initialDataplaceholderData 选项需要符合具有 data.pagesdata.pageParams 属性的对象的相同结构。

示例

假设我们有一个 API,它基于 cursor 索引每次返回 3 个 projects 页面,以及一个可用于获取下一组 projects 的游标

tsx
fetch('/api/projects?cursor=0')
// { data: [...], nextCursor: 3}
fetch('/api/projects?cursor=3')
// { data: [...], nextCursor: 6}
fetch('/api/projects?cursor=6')
// { data: [...], nextCursor: 9}
fetch('/api/projects?cursor=9')
// { data: [...] }
fetch('/api/projects?cursor=0')
// { data: [...], nextCursor: 3}
fetch('/api/projects?cursor=3')
// { data: [...], nextCursor: 6}
fetch('/api/projects?cursor=6')
// { data: [...], nextCursor: 9}
fetch('/api/projects?cursor=9')
// { data: [...] }

有了这些信息,我们可以通过以下方式创建一个“加载更多”UI:

  • 默认情况下等待 useInfiniteQuery 请求第一组数据
  • getNextPageParam 中返回下一个查询的信息
  • 调用 fetchNextPage 函数
tsx
import { useInfiniteQuery } from '@tanstack/react-query'

function Projects() {
  const fetchProjects = async ({ pageParam }) => {
    const res = await fetch('/api/projects?cursor=' + pageParam)
    return res.json()
  }

  const {
    data,
    error,
    fetchNextPage,
    hasNextPage,
    isFetching,
    isFetchingNextPage,
    status,
  } = useInfiniteQuery({
    queryKey: ['projects'],
    queryFn: fetchProjects,
    initialPageParam: 0,
    getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
  })

  return status === 'pending' ? (
    <p>Loading...</p>
  ) : status === 'error' ? (
    <p>Error: {error.message}</p>
  ) : (
    <>
      {data.pages.map((group, i) => (
        <React.Fragment key={i}>
          {group.data.map((project) => (
            <p key={project.id}>{project.name}</p>
          ))}
        </React.Fragment>
      ))}
      <div>
        <button
          onClick={() => fetchNextPage()}
          disabled={!hasNextPage || isFetchingNextPage}
        >
          {isFetchingNextPage
            ? 'Loading more...'
            : hasNextPage
              ? 'Load More'
              : 'Nothing more to load'}
        </button>
      </div>
      <div>{isFetching && !isFetchingNextPage ? 'Fetching...' : null}</div>
    </>
  )
}
import { useInfiniteQuery } from '@tanstack/react-query'

function Projects() {
  const fetchProjects = async ({ pageParam }) => {
    const res = await fetch('/api/projects?cursor=' + pageParam)
    return res.json()
  }

  const {
    data,
    error,
    fetchNextPage,
    hasNextPage,
    isFetching,
    isFetchingNextPage,
    status,
  } = useInfiniteQuery({
    queryKey: ['projects'],
    queryFn: fetchProjects,
    initialPageParam: 0,
    getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
  })

  return status === 'pending' ? (
    <p>Loading...</p>
  ) : status === 'error' ? (
    <p>Error: {error.message}</p>
  ) : (
    <>
      {data.pages.map((group, i) => (
        <React.Fragment key={i}>
          {group.data.map((project) => (
            <p key={project.id}>{project.name}</p>
          ))}
        </React.Fragment>
      ))}
      <div>
        <button
          onClick={() => fetchNextPage()}
          disabled={!hasNextPage || isFetchingNextPage}
        >
          {isFetchingNextPage
            ? 'Loading more...'
            : hasNextPage
              ? 'Load More'
              : 'Nothing more to load'}
        </button>
      </div>
      <div>{isFetching && !isFetchingNextPage ? 'Fetching...' : null}</div>
    </>
  )
}

必须理解的是,在正在进行的获取过程中调用 fetchNextPage 可能会覆盖后台正在发生的数据刷新。当渲染列表并同时触发 fetchNextPage 时,这种情况尤其关键。

请记住,对于 InfiniteQuery 只能有一个正在进行的获取。所有页面共享一个缓存条目,尝试同时获取两次可能会导致数据覆盖。

如果你打算启用同时获取,你可以在 fetchNextPage 中使用 { cancelRefetch: false } 选项(默认值:true)。

为了确保无冲突的无缝查询过程,强烈建议验证查询是否处于 isFetching 状态,尤其是在用户不会直接控制该调用的情况下。

jsx
<List onEndReached={() => !isFetchingNextPage && fetchNextPage()} />
<List onEndReached={() => !isFetchingNextPage && fetchNextPage()} />

当无限查询需要重新获取时会发生什么?

当无限查询变为 stale 并需要重新获取时,每个组都从第一个开始按顺序获取。这确保即使底层数据发生突变,我们也不会使用陈旧的游标,并可能获得重复项或跳过记录。如果无限查询的结果从 queryCache 中删除,分页将从初始状态重新开始,仅请求初始组。

如果我想实现一个双向无限列表怎么办?

双向列表可以通过使用 getPreviousPageParamfetchPreviousPagehasPreviousPageisFetchingPreviousPage 属性和函数来实现。

tsx
useInfiniteQuery({
  queryKey: ['projects'],
  queryFn: fetchProjects,
  initialPageParam: 0,
  getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
  getPreviousPageParam: (firstPage, pages) => firstPage.prevCursor,
})
useInfiniteQuery({
  queryKey: ['projects'],
  queryFn: fetchProjects,
  initialPageParam: 0,
  getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
  getPreviousPageParam: (firstPage, pages) => firstPage.prevCursor,
})

如果我想以相反的顺序显示页面怎么办?

有时你可能想要以相反的顺序显示页面。如果是这种情况,你可以使用 select 选项

tsx
useInfiniteQuery({
  queryKey: ['projects'],
  queryFn: fetchProjects,
  select: (data) => ({
    pages: [...data.pages].reverse(),
    pageParams: [...data.pageParams].reverse(),
  }),
})
useInfiniteQuery({
  queryKey: ['projects'],
  queryFn: fetchProjects,
  select: (data) => ({
    pages: [...data.pages].reverse(),
    pageParams: [...data.pageParams].reverse(),
  }),
})

如果我想手动更新无限查询怎么办?

手动删除第一页:

tsx
queryClient.setQueryData(['projects'], (data) => ({
  pages: data.pages.slice(1),
  pageParams: data.pageParams.slice(1),
}))
queryClient.setQueryData(['projects'], (data) => ({
  pages: data.pages.slice(1),
  pageParams: data.pageParams.slice(1),
}))

手动从单个页面中删除一个值

tsx
const newPagesArray =
  oldPagesArray?.pages.map((page) =>
    page.filter((val) => val.id !== updatedId),
  ) ?? []

queryClient.setQueryData(['projects'], (data) => ({
  pages: newPagesArray,
  pageParams: data.pageParams,
}))
const newPagesArray =
  oldPagesArray?.pages.map((page) =>
    page.filter((val) => val.id !== updatedId),
  ) ?? []

queryClient.setQueryData(['projects'], (data) => ({
  pages: newPagesArray,
  pageParams: data.pageParams,
}))

仅保留第一页

tsx
queryClient.setQueryData(['projects'], (data) => ({
  pages: data.pages.slice(0, 1),
  pageParams: data.pageParams.slice(0, 1),
}))
queryClient.setQueryData(['projects'], (data) => ({
  pages: data.pages.slice(0, 1),
  pageParams: data.pageParams.slice(0, 1),
}))

确保始终保持 pages 和 pageParams 的相同数据结构!

如果我想限制页数怎么办?

在某些用例中,你可能想要限制存储在查询数据中的页数,以提高性能和用户体验

  • 当用户可以加载大量页面时(内存使用)
  • 当你必须重新获取包含数十页的无限查询时(网络使用:所有页面都是按顺序获取的)

解决方案是使用“有限无限查询”。这可以通过结合使用 maxPages 选项以及 getNextPageParamgetPreviousPageParam 来实现,以便在需要时在两个方向上获取页面。

在以下示例中,查询数据 pages 数组中仅保留 3 页。如果需要重新获取,则只会按顺序重新获取 3 页。

tsx
useInfiniteQuery({
  queryKey: ['projects'],
  queryFn: fetchProjects,
  initialPageParam: 0,
  getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
  getPreviousPageParam: (firstPage, pages) => firstPage.prevCursor,
  maxPages: 3,
})
useInfiniteQuery({
  queryKey: ['projects'],
  queryFn: fetchProjects,
  initialPageParam: 0,
  getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
  getPreviousPageParam: (firstPage, pages) => firstPage.prevCursor,
  maxPages: 3,
})

如果我的 API 不返回游标怎么办?

如果你的 API 不返回游标,你可以使用 pageParam 作为游标。因为 getNextPageParamgetPreviousPageParam 也获取当前页面的 pageParam,所以你可以使用它来计算下一个/上一个页面参数。

tsx
return useInfiniteQuery({
  queryKey: ['projects'],
  queryFn: fetchProjects,
  initialPageParam: 0,
  getNextPageParam: (lastPage, allPages, lastPageParam) => {
    if (lastPage.length === 0) {
      return undefined
    }
    return lastPageParam + 1
  },
  getPreviousPageParam: (firstPage, allPages, firstPageParam) => {
    if (firstPageParam <= 1) {
      return undefined
    }
    return firstPageParam - 1
  },
})
return useInfiniteQuery({
  queryKey: ['projects'],
  queryFn: fetchProjects,
  initialPageParam: 0,
  getNextPageParam: (lastPage, allPages, lastPageParam) => {
    if (lastPage.length === 0) {
      return undefined
    }
    return lastPageParam + 1
  },
  getPreviousPageParam: (firstPage, allPages, firstPageParam) => {
    if (firstPageParam <= 1) {
      return undefined
    }
    return firstPageParam - 1
  },
})