← 中文资源

简体中文版本

TanStack Query 无限游标分页

用 useInfiniteQuery 驱动游标式无限列表:nextCursor 穿透页面链、keepPreviousData 防翻页闪烁、滚动前预取下一页。

阅读英文原文

示例说明

无限列表的核心是把分页状态交给查询库托管:useInfiniteQuery 维护页面数组与下一页游标,fetchNextPage 追加页面而不是替换,flatMap 展平全部条目渲染。staleTime 让已加载页面保持稳定,keepPreviousData 保证翻页或刷新瞬间旧内容不消失,按钮的禁用态由 hasNextPage 与取数状态共同决定。

技术要点

  • pageParam 把游标传给取数函数
  • getNextPageParam 决定终止与续取
  • prefetchInfiniteQuery 提前预热下一页

代码解读

fetchFeed 组装带 limit 与 cursor 的查询参数;queryFn 从 pageParam 接收上一页的 nextCursor,首屏为 undefined。getNextPageParam 返回 undefined 即 hasNextPage 为假。结尾的预取调用在用户滚动前就把下一页取到缓存,点击加载时零等待。

使用建议

缓存键要包含影响结果的筛选条件。窗口化渲染长列表配合虚拟滚动组件。预取只在视口接近底部时触发,避免无谓流量。

代码示例

import {
  useInfiniteQuery, queryClient, keepPreviousData,
} from '@tanstack/react-query';

interface FeedPage {
  items: { id: string; title: string }[];
  nextCursor: string | null;
}

async function fetchFeed(cursor?: string): Promise<FeedPage> {
  const params = new URLSearchParams({ limit: '20' });
  if (cursor) params.set('cursor', cursor);

  const res = await fetch('/api/feed?' + params.toString());
  if (!res.ok) throw new Error('feed failed: ' + res.status);
  return res.json();
}

function useFeed() {
  return useInfiniteQuery({
    queryKey: ['feed'],
    queryFn: ({ pageParam }) => fetchFeed(pageParam),
    initialPageParam: undefined as string | undefined,
    getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
    staleTime: 30_000,
    placeholderData: keepPreviousData,
  });
}

function FeedList() {
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useFeed();
  const items = data?.pages.flatMap((page) => page.items) ?? [];

  return (
    <section>
      {items.map((item) => <article key={item.id}>{item.title}</article>)}
      <button
        onClick={() => fetchNextPage()}
        disabled={!hasNextPage || isFetchingNextPage}
      >
        {isFetchingNextPage ? 'loading…' : 'load more'}
      </button>
    </section>
  );
}

queryClient.prefetchInfiniteQuery({
  queryKey: ['feed'],
  queryFn: ({ pageParam }) => fetchFeed(pageParam),
  initialPageParam: undefined as string | undefined,
  pages: 1,
});