基于 TanStack Query 实现 React 分页:keepPreviousData 缓存策略与下一页预取实战
【免费下载链接】query🤖 Powerful asynchronous state management, server-state utilities and data fetching for the web. TS/JS, React Query, Solid Query, Svelte Query and Vue Query.项目地址: https://gitcode.com/GitHub_Trending/qu/query
导读
本文以仓库内 examples/react/pagination 示例为蓝本,讲解如何在 Next.js + React 应用中用 TanStack Query(React Query v5)实现典型的分页加载:翻页时旧数据保持可见、每页数据独立缓存、后台静默预取下一页。读完本文,你将掌握placeholderData: keepPreviousData的核心原理、isPlaceholderData与isFetching的状态判定,以及基于queryClient的预取模式,可直接迁移到自己的分页、表格或列表业务中。
一、示例项目结构与运行方式
该示例位于 examples/react/pagination,是一个最小的 Next.js 应用,结构如下:
- src/pages/index.tsx:前端分页组件与查询逻辑(文章核心);
- src/pages/api/projects.ts:本地 API 路由,模拟带分页的项目列表接口;
- package.json:依赖与脚本声明;
- next.config.js:Next.js 配置;
- README.md:运行说明。
按 README.md 的指引,运行该示例只需两条命令:
npm install npm run dev其中npm run dev实际执行的是next dev --webpack(见 package.json),启动后访问本地开发服务器即可看到分页效果。依赖方面,示例使用@tanstack/react-query ^5.102.8、@tanstack/react-query-devtools ^5.102.8、next ^16.0.7与react ^19.2.1,开发者工具在示例中默认展开,便于直观观察每页查询的缓存状态。
二、先定义分页接口:模拟 API 的设计
后端接口 src/pages/api/projects.ts 通过 Next.js API 路由模拟了一个标准的分页接口,其关键约定是:
- 读取
page查询参数,parseInt(req.query.page) || 0,缺省按第 0 页处理; - 每页固定返回
pageSize = 10条数据; - 返回结构包含
projects数组与hasMore布尔值(page < 9时为true),用于驱动前端"是否有下一页"的判断; - 人为延迟 1000ms,模拟真实网络耗时,方便观察加载态。
const page = parseInt(req.query.page) || 0 const pageSize = 10 const projects = Array(pageSize) .fill(0) .map((_, i) => { const id = page * pageSize + (i + 1) return { name: 'Project ' + id, id } }) await new Promise((r) => setTimeout(r, 1000)) res.json({ projects, hasMore: page < 9 })对应的前端请求函数fetchProjects封装在 src/pages/index.tsx 中,类型为Promise<{ projects: Array<{ name: string; id: number }>; hasMore: boolean }>,直接fetch后返回 JSON。这一"每页独立请求 + hasMore 游标"的接口形态,是所有基于 TanStack Query 分页方案的通用前提。
三、核心实现:placeholderData 与 keepPreviousData
分页场景最直观的痛点是:切换页码时queryKey变化,组件会进入加载状态,页面闪烁、用户丢失浏览位置。本示例的核心解法是给useQuery传入placeholderData: keepPreviousData:
const { status, data, error, isFetching, isPlaceholderData } = useQuery({ queryKey: ['projects', page], queryFn: () => fetchProjects(page), placeholderData: keepPreviousData, staleTime: 5000, })keepPreviousData的实现极其简洁,位于 packages/query-core/src/utils.ts#L418-L422:
export function keepPreviousData<T>( previousData: T | undefined, ): T | undefined { return previousData }它本质是一个恒等函数:当新查询(新页码)还没有数据时,TanStack Query 会把上一次查询的data作为占位数据提供给组件。这带来三个直接收益:
- 翻页时旧数据保持可见:
data要么是最新页的数据,要么在拉取新页时是"上一次成功请求的页面数据",页面不会闪回 Loading; - 每页独立缓存:示例中每个页码都是一个独立的
queryKey(['projects', page]),因此每页数据都会像普通查询一样被缓存。回退到之前的页面时能瞬时显示,同时在后台静默重新校验(refetch); - 无缝衔接预取:占位数据存在时
status不会回到pending,为"后台加载指示 + 预取下一页"创造了条件。
placeholderData 与 staleTime 的配合
示例同时设置了staleTime: 5000,即查询结果在 5 秒内视为新鲜,不会触发重复请求。两者配合的意义在于:keepPreviousData负责"换页时不丢数据",staleTime负责"回看已缓存页面时减少无谓请求",共同保证翻页体验的流畅与省流量。
四、状态字段的正确使用:isPlaceholderData 与 isFetching
useQuery解构出的两个布尔字段是控制 UI 的关键:
isPlaceholderData:为true表示当前展示的是"上一页的占位数据",新页仍在加载。示例用它在两个地方:- 禁用"下一页"按钮:
disabled={isPlaceholderData || !data?.hasMore},避免在不知道下一页是否存在(hasMore 尚未拿到)时允许用户继续翻页; - 作为预取副作用的前提条件(见下节)。
- 禁用"下一页"按钮:
isFetching:表示后台有请求在进行中。由于使用了占位数据,status === 'pending'不会在翻页时触发,此时只能用isFetching渲染一个低调的后台加载指示:
{ // 由于上一页数据会保留在界面上,`status === 'pending'` 不会触发, // 因此用 `isFetching` 显示后台加载指示 isFetching ? <span> Loading...</span> : null }而status的三个分支(pending/error/success)只覆盖"首次加载"与"请求失败"两种情形,配合error.message展示错误信息。这样便形成了完整的状态机:首屏 Loading → 稳定数据 → 翻页时"旧数据 + 后台 loading 指示"。
五、下一页预取:queryClient 驱动的缓存预热
在依赖data、isPlaceholderData、page变化的useEffect中,示例通过useQueryClient()拿到的queryClient主动预取下一页数据:
const queryClient = useQueryClient() // 预取下一页! React.useEffect(() => { if (!isPlaceholderData && data?.hasMore) { queryClient .query({ queryKey: ['projects', page + 1], queryFn: () => fetchProjects(page + 1), }) .catch(noop) } }, [data, isPlaceholderData, page, queryClient])这里有几个值得注意的实现细节:
- 触发条件:
!isPlaceholderData && data?.hasMore保证只有当当前页数据真实就绪(而非占位数据)、且后端明确告知还有下一页时,才发起预取,避免在未知状态下盲目请求; queryClient.query方法:这是 TanStack Query v5 引入的编程式查询入口,效果等价于prefetchQuery但不返回 Promise 结果给 UI,只负责把数据写入缓存(queryKey: ['projects', page + 1])。由于页面组件并不订阅该 key,预取数据会安静地存在于缓存中,等用户真正点击"下一页"时瞬时呈现,再由staleTime: 5000决定是否需要后台刷新;.catch(noop):noop同样从@tanstack/react-query导入(见 src/pages/index.tsx 的 import 语句),用于吞掉预取失败产生的 unhandled rejection——预取是锦上添花,失败不应影响当前页面。
noop与keepPreviousData同属@tanstack/react-query导出的工具函数,二者都在 packages/query-core/src/index.ts 中统一导出,可在任意 TanStack Query 应用直接复用。
六、页面状态与按钮交互逻辑
页码使用 React 本地状态useState(0)管理,并同步作为queryKey的一部分:
const [page, setPage] = React.useState(0)"Previous Page" 按钮通过Math.max(old - 1, 0)保证不会翻到负数页,并在page === 0时禁用;"Next Page" 按钮只有在data?.hasMore为真时才允许页码递增:
<button onClick={() => { setPage((old) => (data?.hasMore ? old + 1 : old)) }} disabled={isPlaceholderData || !data?.hasMore} > Next Page </button>按钮的能力(能否进入下一页)被刻意抑制到"下一页游标(hasMore)已知"之后,这正是示例 README 中描述的设计目标之一:data要么解析为最新页数据,要么在抓取新页时保留上一次成功页面的数据。
七、完整组件代码与要点总结
将上述片段拼合后,前端完整逻辑见 src/pages/index.tsx,其数据流可归纳为四条主线:
| 关注点 | 实现手段 | 作用 |
|---|---|---|
| 翻页不闪烁 | placeholderData: keepPreviousData | 新页未返回时沿用上一页数据 |
| 每页独立缓存 | queryKey: ['projects', page] | 回退页面瞬时显示 + 后台静默 refetch |
| 加载态指示 | isFetching+status | 首屏 Loading、翻页后台指示、错误展示三态分离 |
| 下一页预热 | queryClient.query(...)+noop | 提前写入缓存,点击后秒开 |
这套模式可直接复用到表格分页、商品列表、消息流等任何"逐页拉取 + 预取相邻页"的场景。若想观察每页查询的staleTime到期、缓存命中与预取写入过程,示例默认展开的ReactQueryDevtools(initialIsOpen)是最好的调试工具——切换页码时,你能在 DevTools 中看到['projects', 0]、['projects', 1]等查询条目逐页出现并被标记为新鲜或过期。
最后再次强调运行方式:克隆仓库后进入 examples/react/pagination,依次执行npm install与npm run dev即可启动体验。
【免费下载链接】query🤖 Powerful asynchronous state management, server-state utilities and data fetching for the web. TS/JS, React Query, Solid Query, Svelte Query and Vue Query.项目地址: https://gitcode.com/GitHub_Trending/qu/query
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考