useInfiniteQuery 分页与无限滚动
TanStack Query 的 useInfiniteQuery 实现无限滚动和分页加载,支持双向加载、自定义分页参数、与虚拟化列表集成。
#type / howto
#status / evergreen
#tech / dev / frontend
#resource / react
[!info] related notes
- 前置: TanStack Query 服务端状态
- 所属 MOC: TanStack Query 知识地图
- 关联: placeholderData 与 keepPreviousData
- 关联: 预取策略与 Suspense
useInfiniteQuery 分页与无限滚动
核心问题
传统分页需要用户点”上一页/下一页”。无限滚动(如社交媒体 feed)需要用户滚动时自动加载更多。
两种场景的共同需求:
- 保留已加载的数据(不是替换)
- 知道”还有没有下一页”
- 处理加载中、加载失败、没有更多数据的状态
useInfiniteQuery 基础
基本用法
import { useInfiniteQuery } from '@tanstack/react-query';
function Feed() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
isError,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }) => fetchPosts({ cursor: pageParam }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor, // 返回下一页的参数,undefined 表示没有更多
});
if (isLoading) return <Loading />;
if (isError) return <Error />;
// data.pages 是所有页的数组
const allPosts = data.pages.flatMap(page => page.posts);
return (
<div>
{allPosts.map(post => <PostCard key={post.id} post={post} />)}
{hasNextPage && (
<button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? '加载中...' : '加载更多'}
</button>
)}
{!hasNextPage && <p>没有更多了</p>}
</div>
);
}
数据结构
data = {
pages: [
{ posts: [...], nextCursor: 'abc' }, // 第 1 页
{ posts: [...], nextCursor: 'def' }, // 第 2 页
{ posts: [...], nextCursor: undefined }, // 第 3 页(最后一页)
],
pageParams: [undefined, 'abc', 'def'], // 每次请求用的 pageParam
}
分页参数模式
偏移量分页(offset-based)
useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }) => fetchPosts({ offset: pageParam, limit: 20 }),
initialPageParam: 0,
getNextPageParam: (lastPage, allPages) => {
const nextOffset = allPages.length * 20;
return nextOffset < lastPage.total ? nextOffset : undefined;
},
});
游标分页(cursor-based)
useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }) => fetchPosts({ cursor: pageParam }),
initialPageParam: null,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
});
页码分页(page-based)
useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }) => fetchPosts({ page: pageParam }),
initialPageParam: 1,
getNextPageParam: (lastPage, allPages) => {
return allPages.length < lastPage.totalPages ? allPages.length + 1 : undefined;
},
});
双向加载
聊天场景需要向上翻页加载历史消息:
useInfiniteQuery({
queryKey: ['messages', conversationId],
queryFn: ({ pageParam, direction }) => {
if (direction === 'forward') {
return fetchNewerMessages({ after: pageParam });
}
return fetchOlderMessages({ before: pageParam });
},
initialPageParam: null,
getNextPageParam: (lastPage) => lastPage.nextCursor,
getPreviousPageParam: (firstPage) => firstPage.previousCursor,
});
// 向上加载历史
const { fetchPreviousPage, hasPreviousPage, isFetchingPreviousPage } = query;
自动加载(Intersection Observer)
import { useRef, useEffect } from 'react';
function InfiniteList() {
const loadMoreRef = useRef(null);
const { fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }) => fetchPosts({ cursor: pageParam }),
initialPageParam: undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
// Intersection Observer 自动触发
useEffect(() => {
if (!hasNextPage || isFetchingNextPage) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
fetchNextPage();
}
},
{ threshold: 0.1 }
);
if (loadMoreRef.current) {
observer.observe(loadMoreRef.current);
}
return () => observer.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
// ...
return <div ref={loadMoreRef}>{/* 触发元素 */}</div>;
}
与虚拟化列表集成
大数据量时用虚拟化避免 DOM 过多:
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualizedFeed() {
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }) => fetchPosts({ cursor: pageParam }),
initialPageParam: undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
const allPosts = data?.pages.flatMap(page => page.posts) ?? [];
const parentRef = useRef(null);
const virtualizer = useVirtualizer({
count: allPosts.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 100,
overscan: 5,
});
// 滚动到底部时加载更多
useEffect(() => {
const lastItem = virtualizer.getVirtualItems().at(-1);
if (lastItem && lastItem.index >= allPosts.length - 1 && hasNextPage) {
fetchNextPage();
}
}, [virtualizer.getVirtualItems(), hasNextPage, fetchNextPage]);
return (
<div ref={parentRef} style={{ height: '100vh', overflow: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map(virtualRow => (
<div
key={virtualRow.key}
style={{
position: 'absolute',
top: virtualRow.start,
height: virtualRow.size,
width: '100%',
}}
>
<PostCard post={allPosts[virtualRow.index]} />
</div>
))}
</div>
</div>
);
}
刷新与失效
刷新整个列表
queryClient.invalidateQueries({ queryKey: ['posts'] });
// 会重新请求第一页,丢弃所有已加载的页
只刷新某一页的数据
// 通常不需要,因为 infinite query 的数据是累积的
// 如果需要更新单个帖子,用 setQueryData 更新整个 pages 数组
queryClient.setQueryData(['posts'], (old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map(page => ({
...page,
posts: page.posts.map(p =>
p.id === updatedPost.id ? updatedPost : p
),
})),
};
});
常见错误
getNextPageParam 返回错误类型
// ❌ 返回 null 而不是 undefined
getNextPageParam: (lastPage) => lastPage.nextCursor || null,
// TanStack Query 认为 null 是有效参数,会继续请求
// ✅ 没有下一页时返回 undefined
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
忘记 initialPageParam
// ❌ v5 必须提供 initialPageParam
useInfiniteQuery({
queryFn: ({ pageParam }) => fetchPosts({ cursor: pageParam }),
getNextPageParam: (lastPage) => lastPage.nextCursor,
// 缺少 initialPageParam → 类型错误
});
// ✅ 明确初始值
useInfiniteQuery({
queryFn: ({ pageParam }) => fetchPosts({ cursor: pageParam }),
initialPageParam: undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
数据展平方式错误
// ❌ 忘记展平
const posts = data?.pages; // 这是页数组,不是帖子数组
// ✅ 正确展平
const posts = data?.pages.flatMap(page => page.posts) ?? [];
useInfiniteQuery vs useQuery 分页
| 特性 | useQuery + page state | useInfiniteQuery |
|---|---|---|
| 已加载数据 | 每次只保留当前页 | 保留所有已加载的页 |
| 用户体验 | 点击翻页 | 滚动加载 / 点击加载 |
| 内存 | 只存一页 | 存所有页(大数据量需虚拟化) |
| 适用场景 | 表格分页、搜索结果 | Feed、聊天记录、长列表 |