跳到主要内容

Initial Data、Placeholder Data 与 过渡态

TanStack Query 在真实页面里,除了“能不能拉到数据”,还有一层经常更影响体验:

  • 首屏能不能少一点空白
  • 列表切页时会不会闪一下
  • 详情页切换时能不能先顶住旧内容
  • SSR 回来之后是不是还要重新抖一遍

这类问题,常见会落到几个能力上:

  • initialData
  • placeholderData
  • keepPreviousData
  • staleTime

先分清这几个能力

initialData

initialData 是直接给缓存一个起始值。

useQuery({
queryKey: ['user', userId],
queryFn: () => getUserDetail(userId),
initialData: {
id: userId,
name: 'loading...',
},
})

它的重点不是“页面先不空”,而是:

  • 这份数据会进缓存
  • Query 一开始就不再是 undefined

所以 initialData 更适合:

  • 已经有一份可信初值
  • SSR 已经注入过数据
  • 路由跳转时能从上一个页面拿到部分结果

placeholderData

placeholderData 更像占位内容。

useQuery({
queryKey: ['user', userId],
queryFn: () => getUserDetail(userId),
placeholderData: {
id: userId,
name: 'loading...',
},
})

它和 initialData 的差别在于:

  • placeholderData 不会被当成真实缓存源长期保存
  • 更像“先拿一份临时形状把页面撑住”

这很适合:

  • 骨架之外再多给一层结构占位
  • 详情页先顶一份简化内容
  • 列表切换筛选时,先别让版面塌掉

keepPreviousData

keepPreviousData 更适合分页、筛选、搜索参数变化的场景。

它想解决的是:

  • 参数一变,旧数据立刻清空
  • 页面中间闪出一次 loading
  • 表格、列表体感发抖

最常见的写法是:

import { keepPreviousData, useQuery } from '@tanstack/react-query'

useQuery({
queryKey: ['orders', page],
queryFn: () => getOrderList({ page }),
placeholderData: keepPreviousData,
})

在 v5 里,更常见的是把 keepPreviousData 作为 placeholderData 的一个现成 helper 来用。

首屏、占位、过渡,三者怎么选

可以直接按这条线来记:

  • 有可靠初值:优先看 initialData
  • 只是想先撑住结构:优先看 placeholderData
  • 参数变化时想保留旧页:优先看 keepPreviousData

initialData 最适合的几个来源

1. SSR 注入

服务端已经预取过数据时,dehydrate / HydrationBoundary 往往已经够了。但有些局部场景仍然会手动给 initialData

2. 路由上一个页面

比如列表里已经有一份简化用户信息,进详情页时可以先带过去。

const queryClient = useQueryClient()

useQuery({
queryKey: ['user', userId],
queryFn: () => getUserDetail(userId),
initialData: () =>
queryClient
.getQueryData<Array<{ id: string; name: string }>>(['users'])
?.find((item) => item.id === userId),
})

这类写法很适合:

  • 列表到详情
  • 卡片到详情
  • 搜索结果到详情

3. 本地缓存

某些低频变化的数据,比如用户资料、配置项,也会先从本地缓存里顶一份。

placeholderData 更像 UI 过渡

如果页面不适合直接展示骨架,而又不想一上来就是空白,可以给一份结构相近的 placeholder。

useQuery({
queryKey: ['dashboard'],
queryFn: getDashboard,
placeholderData: {
summary: {
total: 0,
active: 0,
},
charts: [],
},
})

这种写法适合:

  • 仪表盘
  • 表格页
  • 详情页

它的价值主要是让布局稳定,不是拿来伪造真实业务结果。

keepPreviousData 在分页里最常用

一个很典型的分页列表:

function OrderTable({ page }: { page: number }) {
const query = useQuery({
queryKey: ['orders', page],
queryFn: () => getOrderList({ page }),
placeholderData: keepPreviousData,
})

return <Table dataSource={query.data?.list ?? []} loading={query.isFetching} />
}

这里的体验会比较稳:

  • 翻页时旧数据先留着
  • 新页数据回来后再替换
  • 不会每点一次下一页就空白一下

过渡态里还要看 isLoadingisFetching

这两个状态别混。

  • isLoading:通常是第一次完全没数据
  • isFetching:背景里正在重取或切换参数

所以很多页面更常见的写法是:

if (query.isLoading) {
return <PageSkeleton />
}

return (
<>
{query.isFetching && <InlineSpinner />}
<List data={query.data} />
</>
)

这样首屏和过渡态会更自然。

staleTime 的关系

如果 staleTime 很短,页面刚挂载就重取,过渡态会很频繁。

useQuery({
queryKey: ['profile'],
queryFn: getProfile,
staleTime: 60 * 1000,
})

所以这几个能力通常要配着看:

  • initialData
  • placeholderData
  • staleTime
  • refetchOnWindowFocus

很多“页面为什么老在闪”的问题,最后都不是单独一个配置导致的。

一个更接近真实项目的组合

import { keepPreviousData, useQuery } from '@tanstack/react-query'

export function useProductList(params: { page: number; keyword?: string }) {
return useQuery({
queryKey: ['products', params],
queryFn: () => getProductList(params),
placeholderData: keepPreviousData,
staleTime: 30 * 1000,
})
}

这类组合适合:

  • 表格翻页
  • 搜索筛选
  • 后台列表

如果再加上 SSR 预取,首屏和切换体验会更稳。

常见踩坑点

1. 把 placeholderData 当成真实数据源

它更偏 UI 占位,不适合承载需要长期信任的业务结果。

2. initialData 给了一份很旧的数据,却没配好 staleTime

结果就是页面一上来展示旧值,但又立刻抖一下去重取。

3. 需要保留旧页时,没有用 keepPreviousData

这种最容易出现在分页表格,体验会很明显地闪。

4. 把所有页面都强行塞进 placeholder

有些页面骨架屏更自然,有些页面适合保留旧数据,有些页面直接 loading 更清楚,不必统一成一种策略。

更适合先记住的主线

可以直接按页面体验来选:

  • 首屏不想空:看 initialData
  • 结构不想塌:看 placeholderData
  • 翻页不想闪:看 keepPreviousData

这三者捋顺之后,TanStack Query 的页面体验会稳定很多。

相关文章