4.1 两种列表模式
大列表有两种交互:传统分页(上一页/下一页,一次替换)和无限加载(滚动追加,累积展示)。TanStack Query 为两者分别提供了优雅方案。
4.2 核心概念
- placeholderData
- 翻页时的"占位数据"。传
keepPreviousData辅助函数(v5)后,切换到新页时会先展示上一页数据,等新页加载好再替换——避免列表塌陷成空白 loading。 - keepPreviousData
- v5 提供的辅助函数:
placeholderData: keepPreviousData。它让分页体验从"每页闪一下"变成"平滑过渡"。旧版本是keepPreviousData: true选项。 - isPlaceholderData
- 布尔标志,为 true 表示当前展示的是上一页的占位数据(新页还在加载)。可据此禁用"下一页"按钮,防止越点越乱。
- useInfiniteQuery
- 专为无限加载设计的 Hook。它把每次获取的结果按"页"累积在
data.pages数组里,配合 fetchNextPage 实现追加加载。 - getNextPageParam
- useInfiniteQuery 必填函数:接收最后一页数据,返回下一页的参数(如下一页游标或页码),返回
undefined表示没有更多了。 - pageParam
- 传给 queryFn 的当前页参数,初始值由
initialPageParam指定,后续由 getNextPageParam 计算得出。 - hasNextPage / fetchNextPage
- hasNextPage 表示是否还有下一页(getNextPageParam 是否返回了值);fetchNextPage 触发加载下一页。二者驱动"加载更多"按钮或无限滚动。
4.3 传统分页 + 平滑过渡
import { useQuery, keepPreviousData } from "@tanstack/react-query";
function Projects() {
const [page, setPage] = useState(1);
const { data, isPlaceholderData, isLoading } = useQuery({
queryKey: ["projects", page],
queryFn: () => fetchProjects(page),
placeholderData: keepPreviousData, // 翻页保留旧数据
staleTime: 5000,
});
return (
<div>
<ul style={{ opacity: isPlaceholderData ? 0.5 : 1 }}>
{data?.items.map(p => <li key={p.id}>{p.name}</li>)}
</ul>
<button onClick={() => setPage(p => p - 1)} disabled={page === 1}>上一页</button>
<button
onClick={() => setPage(p => p + 1)}
disabled={isPlaceholderData || !data?.hasMore}
>下一页</button>
</div>
);
}
为什么不闪了
没有 placeholderData 时,page 变化会让 data 瞬间变 undefined,列表塌陷。用 keepPreviousData 后,data 保持上一页内容直到新页就绪,只是标记 isPlaceholderData=true,配合半透明即可给出"加载中"暗示。
4.4 无限加载 useInfiniteQuery
import { useInfiniteQuery } from "@tanstack/react-query";
function Feed() {
const {
data, fetchNextPage, hasNextPage, isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ["feed"],
queryFn: ({ pageParam }) => fetchFeed(pageParam),
initialPageParam: 0,
getNextPageParam: (lastPage) =>
lastPage.nextCursor ?? undefined, // 无更多则 undefined
});
return (
<div>
{data?.pages.map((page, i) =>
page.items.map(item => <Post key={item.id} data={item} />)
)}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage ? "加载中…" : hasNextPage ? "加载更多" : "没有更多了"}
</button>
</div>
);
}
data 的结构变了
useInfiniteQuery 的 data 不是扁平数组,而是 { pages: [...], pageParams: [...] }。渲染时要 data.pages.flatMap(p => p.items) 或双层 map。想反向(历史消息往上加载)还有 getPreviousPageParam + fetchPreviousPage。
4.5 游标分页 vs 偏移分页
| 方式 | getNextPageParam 返回 | 适用 |
|---|---|---|
| 偏移(offset/page) | lastPage.page + 1 | 数据稳定的后台列表 |
| 游标(cursor) | lastPage.nextCursor | 实时流、社交 feed(避免重复/漏项) |
| 时间戳 | lastPage.items.at(-1)?.createdAt | 消息、日志按时间倒序 |
4.6 配合虚拟滚动
无限加载 + 上万条数据时,DOM 节点过多会卡。用 @tanstack/react-virtual 只渲染可视区,并在滚到底部附近时触发 fetchNextPage:
const rows = data?.pages.flatMap(p => p.items) ?? [];
const virtualizer = useVirtualizer({
count: hasNextPage ? rows.length + 1 : rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 64,
});
// 当最后一个虚拟项进入视图 → 加载下一页
useEffect(() => {
const last = virtualizer.getVirtualItems().at(-1);
if (last && last.index >= rows.length - 1 && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [virtualizer.getVirtualItems()]);
4.7 小结与下一步
- 传统分页用
placeholderData: keepPreviousData消除翻页闪烁。 - 无限加载用 useInfiniteQuery,data 变成 pages 数组,需 flatMap 渲染。
- getNextPageParam 返回下一页参数,undefined 表示到底;游标分页最稳。
- 大数据量配合虚拟滚动,滚到底部触发 fetchNextPage。
- 下一章进入写操作 useMutation——变更、失效与乐观更新。