Skip to content
无限查询与分页

无限查询与分页

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 是第一页请求时传给 queryFnpageParam 值。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