TL;DR: TanStack Query 的
useInfiniteQuery内置了页面积累、方向控制和预加载的状态管理,可处理游标和 offset 分页。本文涵盖:基于游标分页(TanStack Start 服务端函数)、聊天气式 UI 的反向分页、无限列表的 stale-while-revalidate,以及实时更新的乐观前置追加。
Introduction
分页看似简单,实则陷阱重重。Offset 分页在数据增删时就会出问题。游标分页需要精细的状态管理。无限滚动在预加载、竞态条件和内存增长方面都有边界情况。
TanStack Query 的 useInfiniteQuery 将这些复杂性抽象成大部分。它管理页面积累、提供 fetchNextPage 和 fetchPreviousPage 回调,并与 TanStack Router 的预加载系统天然集成。
使用服务端函数实现游标分页
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
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 实现无限滚动
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 则涵盖高效分页的底层数据结构设计。