分页与无限查询
本教程共 38 篇 · 第 13 篇 · 更新于 2026-07-27 · 约 10 分钟阅读
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标志告诉你当前显示的是不是占位数据
Notev5 移除了 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 接收当前页数据,返回下一页的参数。返回 undefined 或 null 表示没有下一页:
getNextPageParam: (lastPage, allPages) => {
// lastPage 是最后一页的数据
// 返回下一页的 cursor,没有就返回 undefined
return lastPage.nextCursor
}
返回值会作为下一次 fetchNextPage 时的 pageParam 传给 queryFn。hasNextPage 就是根据这个返回值是不是 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 双向无限查询
有些场景需要往上往下都能加载(比如聊天记录)。用 getPreviousPageParam 和 fetchPreviousPage:
useInfiniteQuery({
queryKey: ['projects'],
queryFn: fetchProjects,
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
getPreviousPageParam: (firstPage) => firstPage.prevCursor,
})
这样就有 fetchPreviousPage、hasPreviousPage、isFetchingPreviousPage 可用。
反转显示顺序
想最新的在上面、旧的在下面,用 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要求同时定义getNextPageParam和getPreviousPageParam,因为它是双向的。只往下加载的场景用不了。
13.8 手动操作无限查询缓存
有时候要手动改无限查询的数据(比如删掉某一项)。用 setQueryData,注意保持 pages 和 pageParams 结构:
// 删掉第一页
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改完一定要保持
pages和pageParams结构一致,长度对得上。结构乱了会导致后续fetchNextPage行为异常。
13.9 placeholderData 详解
除了分页用的 keepPreviousData,placeholderData 还有别的用法。
固定占位值
给查询一个假数据,加载时不显示 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
placeholderData和initialData的区别: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 章)会讲并行查询、依赖查询和预取,继续深入。