TanStack Query: Infinite Queries 和游标分页

使用 TanStack Query 实现高效的无限滚动和游标分页,涵盖 useInfiniteQuery 模式、预加载和数据同步。

Sam Rivera
Sam Rivera
June 1, 202611 min read
其他语言:Deutsch · English

TL;DR: TanStack Query 的 useInfiniteQuery 内置了页面积累、方向控制和预加载的状态管理,可处理游标和 offset 分页。本文涵盖:基于游标分页(TanStack Start 服务端函数)、聊天气式 UI 的反向分页、无限列表的 stale-while-revalidate,以及实时更新的乐观前置追加。


Introduction

分页看似简单,实则陷阱重重。Offset 分页在数据增删时就会出问题。游标分页需要精细的状态管理。无限滚动在预加载、竞态条件和内存增长方面都有边界情况。

TanStack Query 的 useInfiniteQuery 将这些复杂性抽象成大部分。它管理页面积累、提供 fetchNextPage 和 fetchPreviousPage 回调,并与 TanStack Router 的预加载系统天然集成。


使用服务端函数实现游标分页

tsx
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
import { db } from '../db'

export const listProductsFn = createServerFn({ method: 'GET' })
  .validator(z.object({
    cursor: z.string().optional(),
    limit: z.number().default(20),
  }))
  .handler(async ({ data }) => {
    const { cursor, limit } = data
    const products = await db
      .selectFrom('products')
      .orderBy('createdAt', 'desc')
      .limit(limit + 1)
      .cursor(cursor ? { after: cursor } : undefined)
      .execute()

    const hasMore = products.length > limit
    const items = hasMore ? products.slice(0, limit) : products
    const nextCursor = hasMore ? items[items.length - 1].id : null

    return { items, nextCursor }
  })

useInfiniteQuery Hook

tsx
import { useInfiniteQuery } from '@tanstack/react-query'
import { listProductsFn } from '../server/products'

function ProductList() {
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
    useInfiniteQuery({
      queryKey: ['products'],
      queryFn: ({ pageParam }) =>
        listProductsFn({ data: { cursor: pageParam } }),
      initialPageParam: undefined,
      getNextPageParam: (lastPage) => lastPage.nextCursor,
    })

  return (
    <div>
      {data?.pages.map((page) =>
        page.items.map((product) => <ProductCard key={product.id} product={product} />)
      )}
      {hasNextPage && (
        <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
          {isFetchingNextPage ? 'Loading...' : 'Load More'}
        </button>
      )}
    </div>
  )
}

使用 Intersection Observer 实现无限滚动

tsx
function InfiniteProductList() {
  const observerRef = useRef<HTMLDivElement>(null)

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
          fetchNextPage()
        }
      },
      { threshold: 0.1 }
    )
    if (observerRef.current) observer.observe(observerRef.current)
    return () => observer.disconnect()
  }, [fetchNextPage, hasNextPage, isFetchingNextPage])

  return (
    <div>
      {items}
      <div ref={observerRef} />
    </div>
  )
}

分页策略对比

策略优势劣势适用场景
Offset(LIMIT/OFFSET)简单,可跳转到第 N 页数据增删时失效静态数据集
游标(keyset)稳定,大数据量下速度快无法跳过页面动态信息流
时间分页自然排序每页数据量不均活动流
Search-after(elastic)支持全文检索依赖特定数据库搜索结果

Conclusion

useInfiniteQuery 配合基于游标的分页,是动态数据流场景下用户体验最优的方案。结合 TanStack Start 服务端函数,可以实现从数据库到 UI 的类型安全分页,样板代码极少。

服务端分页模式详见 TanStack Query: Server Functions Best Practices。掌握缓存管理可阅读 Cache Invalidation Strategies。如需渲染大型分页列表,TanStack Virtual Scrolling 与无限查询天然搭配,SaaS Database Architecture 则涵盖高效分页的底层数据结构设计。