无限查询与分页
useInfiniteQuery 是 TanStack Query 处理"加载更多"(Load More / Infinite Scroll)场景的核心 API。它基于游标(cursor)的分页机制,与传统基于偏移量(offset/page)的分页有本质区别。
useInfiniteQuery 完整签名
const result = useInfiniteQuery({
queryKey: ['projects'],
queryFn: ({ pageParam }) => fetchProjects(pageParam),
initialPageParam: 0, // v5 必填!起始游标
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => {
return lastPage.nextCursor ?? undefined
},
getPreviousPageParam: (firstPage, allPages, firstPageParam, allPageParams) => {
return firstPage.prevCursor ?? undefined
},
maxPages: 10, // v5:限制内存中的页数
// ... 所有 useQuery 的其他选项都可用
})核心概念:游标分页 vs 偏移分页
| 维度 | 游标分页(Cursor) | 偏移分页(Offset/Page) |
|---|---|---|
| API 参数 | ?cursor=xxx&limit=20 |
?page=3&size=20 |
| 适用场景 | 无限滚动、实时 Feed | 传统表格分页、跳页 |
| 并发写入稳定性 | ✅ 不变 | ❌ 新数据插入导致重复或遗漏 |
| 跳页能力 | ❌ 一般不能跳 | ✅ 可任意跳转 |
| TanStack Query API | useInfiniteQuery |
useQuery + 页码作为查询键 |
// 游标分页(useInfiniteQuery)
useInfiniteQuery({
queryKey: ['feed'],
queryFn: ({ pageParam }) => fetchFeed(pageParam),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
// 偏移分页(useQuery + 页码键)
useQuery({
queryKey: ['projects', page],
queryFn: () => fetchProjects(page),
placeholderData: keepPreviousData,
})initialPageParam(v5 必填)
initialPageParam 是第一页请求时传给 queryFn 的 pageParam 值。v5 中它是必填的:
// 数字游标
initialPageParam: 0,
// 字符串游标
initialPageParam: undefined as string | undefined,
// offset 偏移
initialPageParam: 0,getNextPageParam / getPreviousPageParam
这两个函数决定分页的方向和终止条件:
useInfiniteQuery({
queryFn: async ({ pageParam = 0 }) => {
const res = await fetch(`/api/projects?cursor=${pageParam}&limit=10`)
return res.json() // { items: Project[], nextCursor: number | null }
},
initialPageParam: 0,
getNextPageParam: (lastPage) => {
// 返回下一游标 → TanStack Query 会自动请求下一页
// 返回 undefined / null → 没有更多页,停止
return lastPage.nextCursor ?? undefined
},
})getNextPageParam 参数
getNextPageParam: (
lastPage: TData, // 最后一页的数据
allPages: TData[], // 所有已加载页的数组
lastPageParam: TPageParam, // 最后一页的 pageParam
allPageParams: TPageParam[], // 所有已用 pageParam 的数组
) => TPageParam | undefined | null返回值
const {
data, // { pages: TData[], pageParams: TPageParam[] }
// data.pages: 每一页的数据数组
// data.pageParams: 每页的 pageParam 数组
fetchNextPage, // () => Promise<void> — 加载下一页
fetchPreviousPage, // () => Promise<void> — 加载上一页
hasNextPage, // boolean — 是否有下一页
hasPreviousPage, // boolean — 是否有上一页
isFetchingNextPage, // boolean — 正在加载下一页
isFetchingPreviousPage, // boolean — 正在加载上一页
// ... 所有 useQuery 的字段(isPending, isFetching, error 等)
} = useInfiniteQuery(...)完整示例:无限滚动
interface ProjectsResponse {
items: Project[]
nextCursor: number | null
}
function ProjectList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isPending,
isError,
error,
} = useInfiniteQuery({
queryKey: ['projects'],
queryFn: async ({ pageParam = 0, signal }) => {
const res = await fetch(`/api/projects?cursor=${pageParam}&limit=10`, { signal })
if (!res.ok) throw new Error('加载失败')
return res.json() as Promise<ProjectsResponse>
},
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
})
if (isPending) return <Spinner />
if (isError) return <Error message={error.message} />
return (
<div>
{data.pages.map((page, i) => (
<Fragment key={i}>
{page.items.map(project => (
<ProjectCard key={project.id} project={project} />
))}
</Fragment>
))}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage
? '加载中...'
: hasNextPage
? '加载更多'
: '没有更多了'}
</button>
</div>
)
}使用 IntersectionObserver 自动加载
function ProjectList() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({...})
// 使用 ref 监听底部
const loadMoreRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const el = loadMoreRef.current
if (!el) return
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
},
{ threshold: 0.1 }
)
observer.observe(el)
return () => observer.disconnect()
}, [fetchNextPage, hasNextPage, isFetchingNextPage])
return (
<div>
{data?.pages.map(...)}
{/* 哨兵元素 */}
<div ref={loadMoreRef} style={{ height: 1 }} />
</div>
)
}maxPages — 内存限制(v5 关键特性)
无限滚动最危险的问题是内存无限增长。用户滚动很久后,data.pages 可能包含数百页数据。v5 新增 maxPages 限制内存中的页数:
useInfiniteQuery({
queryKey: ['feed'],
queryFn: fetchFeed,
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
maxPages: 5, // 只保留最近的 5 页 → 90% 内存减少
})💡 最佳实践:始终设置
maxPages。对于无限滚动,5~10 页通常足够(用户很少往回滚很远)。配合虚拟列表(TanStack Virtual)使用效果更佳。
双向无限滚动
useInfiniteQuery({
queryKey: ['chat-messages', roomId],
queryFn: ({ pageParam }) => fetchMessages(roomId, pageParam),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor, // 向下滚动(更新的消息)
getPreviousPageParam: (firstPage) => firstPage.prevCursor, // 向上滚动(历史消息)
})结合 TanStack Virtual(虚拟列表)
对于长列表,结合 @tanstack/react-virtual 减少 DOM 节点:
import { useVirtualizer } from '@tanstack/react-virtual'
function VirtualInfiniteList() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({...})
// 将所有页的数据展平为一维数组
const allRows = data ? data.pages.flatMap(page => page.items) : []
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: hasNextPage ? allRows.length + 1 : allRows.length, // +1 用于加载指示器
getScrollElement: () => parentRef.current,
estimateSize: () => 50,
overscan: 5,
})
// 滚动到底部时加载更多
const items = virtualizer.getVirtualItems()
useEffect(() => {
const lastItem = items[items.length - 1]
if (lastItem && lastItem.index >= allRows.length - 1 && hasNextPage) {
fetchNextPage()
}
}, [items, allRows.length, hasNextPage, fetchNextPage])
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{items.map(virtualRow => (
<div
key={virtualRow.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: virtualRow.size,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{allRows[virtualRow.index]?.title ?? '加载中...'}
</div>
))}
</div>
</div>
)
}无限查询的常见操作
刷新(重置到第一页)
const { refetch } = useInfiniteQuery({...})
// 手动刷新:清除已加载的页面,从第一页重新开始
function handleRefresh() {
refetch() // 这会重新获取所有页(保持当前 pageParams)
}在 mutation 后更新单条数据
const mutation = useMutation({
mutationFn: updateProject,
onSuccess: (updatedProject) => {
// 更新无限列表中某条数据
queryClient.setQueryData(['projects'], (old: InfiniteData<ProjectsResponse>) => {
if (!old) return old
return {
...old,
pages: old.pages.map(page => ({
...page,
items: page.items.map(p =>
p.id === updatedProject.id ? updatedProject : p
),
})),
}
})
},
})mutation 后使整个列表失效
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects'] })
// ⚠️ 默认只重取第一页。如需重取所有已加载的页面:
// queryClient.invalidateQueries({
// queryKey: ['projects'],
// refetchType: 'all', // 包括 inactive 页面
// })
}与传统分页的对比与选择
需要"加载更多" / 无限滚动?
├── 是 → useInfiniteQuery(游标分页)
└── 否 → 需要跳页 / 总数 / 传统分页?
├── 是 → useQuery + 页码键 + placeholderData: keepPreviousData
└── 否 → 单次 useQuery| 场景 | 方案 |
|---|---|
| 社交媒体 Feed | useInfiniteQuery + 游标 |
| 聊天消息(双向) | useInfiniteQuery + 双向游标 |
| 后台管理表格(可跳页) | useQuery + 页码作为查询键 |
| 搜索引擎结果 | useInfiniteQuery + maxPages: 3 |
| 下拉加载(下拉刷新) | useInfiniteQuery + fetchPreviousPage |