首页 / TanStack 生态入门教程 / 分页与无限查询

TanStack 生态入门教程

分页与无限查询

本教程共 38 篇 · 第 13 篇 · 更新于 2026-07-27 · 约 10 分钟阅读

TanStackTanStack 生态入门教程分页useInfiniteQueryplaceholderDatakeepPreviousData无限滚动getNextPageParam

13. 分页与无限查询

本节目标:掌握分页查询的优化技巧、useInfiniteQuery 的完整用法、双向无限查询和 maxPages 限制,学完能做出丝滑的分页列表和无限滚动效果。

13.1 分页查询的基本写法

分页是列表页最常见的场景。在 Query 里,把页码放进 queryKey 就行:

const result = useQuery({
  queryKey: ['projects', page],
  queryFn: () => fetchProjects(page),
})

页码变了,key 变了,自动请求新页数据。逻辑上没问题,但体验有个痛点:每次翻页,界面会在 success 和 pending 之间跳

因为每一页是独立的查询,切页时旧查询的数据没了,新查询还在加载,用户看到的是 loading 闪烁。

13.2 用 placeholderData 优化

placeholderData 能让新查询在加载时,继续显示上一页的数据,避免闪烁。

import { keepPreviousData, useQuery } from '@tanstack/react-query'

const { isPending, isError, error, data, isFetching, isPlaceholderData } =
  useQuery({
    queryKey: ['projects', page],
    queryFn: () => fetchProjects(page),
    placeholderData: keepPreviousData,
  })

keepPreviousData 是 Query 导出的辅助函数,效果是「key 变了但新数据还没来时,继续用旧数据当占位」。

加了这个之后:

  • 翻页时旧数据还在,不会闪 loading
  • 新数据到了无缝替换
  • isPlaceholderData 标志告诉你当前显示的是不是占位数据
Note

v5 移除了 v4 的 keepPreviousData: true 选项,改用 placeholderData: keepPreviousData。这是 v5 的破坏性变更之一。老代码迁移时注意改。

13.3 完整的分页示例

import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { useState } from 'react'

function Projects() {
  const [page, setPage] = useState(0)

  const fetchProjects = (page = 0) =>
    fetch('/api/projects?page=' + page).then((res) => res.json())

  const {
    isPending,
    isError,
    error,
    data,
    isFetching,
    isPlaceholderData,
  } = useQuery({
    queryKey: ['projects', page],
    queryFn: () => fetchProjects(page),
    placeholderData: keepPreviousData,
  })

  return (
    <div>
      {isPending ? (
        <div>加载中...</div>
      ) : isError ? (
        <div>出错了:{error.message}</div>
      ) : (
        <div>
          {data.projects.map((project) => (
            <p key={project.id}>{project.name}</p>
          ))}
        </div>
      )}

      <span>当前页:{page + 1}</span>

      <button
        onClick={() => setPage((old) => Math.max(old - 1, 0))}
        disabled={page === 0}
      >
        上一页
      </button>

      <button
        onClick={() => {
          // 知道有下一页才翻
          if (!isPlaceholderData && data.hasMore) {
            setPage((old) => old + 1)
          }
        }}
        disabled={isPlaceholderData || !data?.hasMore}
      >
        下一页
      </button>

      {/* 后台刷新指示 */}
      {isFetching && <span>刷新中...</span>}
    </div>
  )
}

几个细节:

  • 上一页按钮在第一页时禁用(page === 0
  • 下一页按钮在「正在加载占位数据」或「没有更多页」时禁用
  • isFetching 显示后台刷新状态,翻页时短暂出现

13.4 无限查询:useInfiniteQuery

分页是「换页」,无限滚动是「往下加」。用 useInfiniteQuery 实现。

假设有个接口用 cursor 分页:

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: [...] }  没了

useInfiniteQuery 这样写:

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) => lastPage.nextCursor,
  })

  // ...
}

和 useQuery 的区别

useInfiniteQuery 返回的数据结构不一样:

  • data.pages — 数组,每项是一页的数据
  • data.pageParams — 数组,每页用的参数
  • fetchNextPage — 加载下一页
  • hasNextPage — 是否还有下一页
  • isFetchingNextPage — 是否正在加载下一页(区别于后台刷新)

v5 要求传 initialPageParam,这是起始页参数。v4 可以在 queryFn 里给 pageParam 默认值,v5 不行了,必须显式传。

渲染数据

data.pages 是个数组,每项是一页,要双层遍历:

return status === 'pending' ? (
  <p>加载中...</p>
) : status === 'error' ? (
  <p>出错了:{error.message}</p>
) : (
  <>
    {data.pages.map((group, i) => (
      <div key={i}>
        {group.data.map((project) => (
          <p key={project.id}>{project.name}</p>
        ))}
      </div>
    ))}
    <div>
      <button
        onClick={() => fetchNextPage()}
        disabled={!hasNextPage || isFetching}
      >
        {isFetchingNextPage
          ? '加载更多...'
          : hasNextPage
            ? '加载更多'
            : '没有更多了'}
      </button>
    </div>
    {isFetching && !isFetchingNextPage && <span>刷新中...</span>}
  </>
)

getNextPageParam 的逻辑

getNextPageParam 接收当前页数据,返回下一页的参数。返回 undefinednull 表示没有下一页:

getNextPageParam: (lastPage, allPages) => {
  // lastPage 是最后一页的数据
  // 返回下一页的 cursor,没有就返回 undefined
  return lastPage.nextCursor
}

返回值会作为下一次 fetchNextPage 时的 pageParam 传给 queryFnhasNextPage 就是根据这个返回值是不是 null/undefined 来判断的。

Tip

如果你的 API 不返回 cursor,只有页码,可以用 allPages.length 计算下一页页码:

getNextPageParam: (lastPage, allPages, lastPageParam) => {
  if (lastPage.length === 0) return undefined  // 空页说明没数据了
  return lastPageParam + 1
}

防止重复 fetch

调用 fetchNextPage 时如果已经在 fetching,可能覆盖正在进行的后台刷新。滚动加载场景要加判断:

<List onEndReached={() => {
  if (hasNextPage && !isFetching) {
    fetchNextPage()
  }
}} />

或者用 cancelRefetch: false 允许并发(但可能数据错乱,慎用):

fetchNextPage({ cancelRefetch: false })

13.5 重新请求的行为

无限查询变 stale 后重新请求,会从第一页开始按顺序逐页重取。这样即使数据变了,cursor 也不会错位,避免重复或漏数据。

如果查询被垃圾回收了(gcTime 到了),重新挂载时只请求第一页,之前加载的页都没了。

13.6 双向无限查询

有些场景需要往上往下都能加载(比如聊天记录)。用 getPreviousPageParamfetchPreviousPage

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

这样就有 fetchPreviousPagehasPreviousPageisFetchingPreviousPage 可用。

反转显示顺序

想最新的在上面、旧的在下面,用 select 反转:

useInfiniteQuery({
  queryKey: ['projects'],
  queryFn: fetchProjects,
  initialPageParam: 0,
  getNextPageParam: (lastPage) => lastPage.nextCursor,
  select: (data) => ({
    pages: [...data.pages].reverse(),
    pageParams: [...data.pageParams].reverse(),
  }),
})

13.7 maxPages:限制页数

无限加载多了,内存涨、重取慢。v5 新增 maxPages 限制保留的页数:

useInfiniteQuery({
  queryKey: ['projects'],
  queryFn: fetchProjects,
  initialPageParam: 0,
  getNextPageParam: (lastPage) => lastPage.nextCursor,
  getPreviousPageParam: (firstPage) => firstPage.prevCursor,
  maxPages: 3,  // 只保留 3 页
})

超过 3 页后,最旧的页会被丢弃。双向都能加载,往前翻会重新请求被丢弃的页。重取时也只重取这 3 页,省网络。

Note

maxPages 要求同时定义 getNextPageParamgetPreviousPageParam,因为它是双向的。只往下加载的场景用不了。

13.8 手动操作无限查询缓存

有时候要手动改无限查询的数据(比如删掉某一项)。用 setQueryData,注意保持 pagespageParams 结构:

// 删掉第一页
queryClient.setQueryData(['projects'], (data) => ({
  pages: data.pages.slice(1),
  pageParams: data.pageParams.slice(1),
}))

// 删掉某一项
queryClient.setQueryData(['projects'], (data) => ({
  pages: data.pages.map((page) =>
    page.filter((item) => item.id !== targetId)
  ),
  pageParams: data.pageParams,
}))
Warning

改完一定要保持 pagespageParams 结构一致,长度对得上。结构乱了会导致后续 fetchNextPage 行为异常。

13.9 placeholderData 详解

除了分页用的 keepPreviousDataplaceholderData 还有别的用法。

固定占位值

给查询一个假数据,加载时不显示 loading 而是显示占位数据:

const placeholderTodos = [
  { id: 0, title: '加载中...' },
  { id: 1, title: '加载中...' },
]

useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  placeholderData: placeholderTodos,
})

查询一开始就是 success 状态(因为有占位数据),isPlaceholderData 为 true。真数据来了替换占位数据。

适合做骨架屏(skeleton)效果。

从其他查询缓存拿占位数据

列表查询有简略数据,详情查询可以拿列表里的简略数据当占位:

function BlogPost({ blogPostId }) {
  const queryClient = useQueryClient()

  const result = useQuery({
    queryKey: ['blogPost', blogPostId],
    queryFn: () => fetch(`/blogPosts/${blogPostId}`),
    placeholderData: () => {
      // 从列表缓存里找这条的简略数据
      return queryClient
        .getQueryData(['blogPosts'])
        ?.find((d) => d.id === blogPostId)
    },
  })
}

用户点进详情页时,列表里的简略数据先顶上,完整数据加载后替换。体验比干等 loading 好很多。

Tip

placeholderDatainitialData 的区别:placeholderData 不进缓存,只是临时显示;initialData 会进缓存当真实数据。占位用 placeholderData,预取用 initialData。

13.10 placeholderData 记忆化

如果生成占位数据很耗时(比如生成一堆假数据),用 useMemo 缓存,别每次渲染都算:

const placeholderData = useMemo(() => generateFakeTodos(), [])

const result = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  placeholderData,
})

13.11 小结

分页和无限查询的要点:

  • 分页:把页码放 key 里,配 placeholderData: keepPreviousData 防闪烁
  • 无限查询useInfiniteQuery + initialPageParam + getNextPageParam,v5 必须传 initialPageParam
  • 双向:加 getPreviousPageParam,用 select 反转顺序
  • 限制页数maxPages 控制内存和重取开销
  • 占位数据:固定值做骨架屏,函数形式从其他缓存取简略数据

至此,TanStack Query 的核心用法讲完了。下一篇(第 14 章)会讲并行查询、依赖查询和预取,继续深入。