Chapter 04

分页与无限加载

用 placeholderData 消除翻页闪烁,用 useInfiniteQuery 实现"加载更多"与瀑布流。

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 小结与下一步