placeholderData 与 keepPreviousData
切换查询参数时,TanStack Query 默认会进入 loading 状态。placeholderData 和 keepPreviousData 可以在新数据加载期间保留旧数据,避免 UI 闪烁。
#type / howto
#status / evergreen
#tech / dev / frontend
#resource / react
[!info] related notes
- 前置: TanStack Query 服务端状态
- 所属 MOC: TanStack Query 知识地图
- 关联: useInfiniteQuery 分页与无限滚动
- 关联: select 与数据转换
placeholderData 与 keepPreviousData
核心问题
const [page, setPage] = useState(1);
const { data } = useQuery({
queryKey: ['users', page],
queryFn: () => fetchUsers(page),
});
当 page 从 1 变到 2 时:
- queryKey 变化,触发新请求
- 旧缓存(page 1)不再匹配新 key
data变成undefined- UI 显示 loading → 新数据到达 → UI 显示数据
用户看到一次闪烁:内容消失 → loading → 内容出现。
解法一:keepPreviousData(v4)
const { data, isPlaceholderData } = useQuery({
queryKey: ['users', page],
queryFn: () => fetchUsers(page),
keepPreviousData: true,
});
切换时 data 保持为旧数据,直到新数据到达。isPlaceholderData 为 true 表示当前显示的是旧数据。
v5 中的替代
v5 移除了 keepPreviousData,改用 placeholderData:
import { keepPreviousData } from '@tanstack/react-query';
const { data, isPlaceholderData } = useQuery({
queryKey: ['users', page],
queryFn: () => fetchUsers(page),
placeholderData: keepPreviousData,
});
解法二:placeholderData(自定义)
placeholderData 可以传入任意数据作为占位:
// 固定占位
const { data } = useQuery({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
placeholderData: { name: '加载中...', avatar: '' },
});
// 从已有缓存中取
const { data } = useQuery({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
placeholderData: () => {
// 从 users 列表缓存中找到这个用户
const users = queryClient.getQueryData(['users']);
return users?.find(u => u.id === id);
},
});
对比
| 特性 | keepPreviousData | 自定义 placeholderData |
|---|---|---|
| 数据来源 | 自动保留上一次 query 的数据 | 手动提供任意数据 |
isPlaceholderData | ✅ | ✅ |
| 适用场景 | 分页、过滤切换 | 从缓存预填、骨架数据 |
| 类型安全 | 自动 | 需要匹配返回类型 |
实战:分页表格
function UserTable() {
const [page, setPage] = useState(1);
const queryClient = useQueryClient();
const { data, isPlaceholderData, isFetching } = useQuery({
queryKey: ['users', page],
queryFn: () => fetchUsers(page),
placeholderData: keepPreviousData,
});
// 预取下一页
useEffect(() => {
if (!isPlaceholderData) {
queryClient.prefetchQuery({
queryKey: ['users', page + 1],
queryFn: () => fetchUsers(page + 1),
});
}
}, [page, isPlaceholderData, queryClient]);
return (
<div>
{/* 用 isPlaceholderData 控制视觉反馈 */}
<table style={{ opacity: isPlaceholderData ? 0.6 : 1 }}>
<tbody>
{data?.users.map(user => (
<tr key={user.id}><td>{user.name}</td></tr>
))}
</tbody>
</table>
{/* isFetching 表示正在加载(包括 placeholder 显示期间) */}
{isFetching && <Spinner />}
<button onClick={() => setPage(p => p - 1)} disabled={page === 1}>
上一页
</button>
<button onClick={() => setPage(p => p + 1)}>
下一页
</button>
</div>
);
}
常见错误
混淆 isPlaceholderData 和 isLoading
const { data, isLoading, isPlaceholderData } = useQuery({
queryKey: ['users', page],
queryFn: fetchUsers,
placeholderData: keepPreviousData,
});
// isLoading:首次加载,没有缓存也没有 placeholder
// isPlaceholderData:有数据但它是 placeholder(旧数据)
// ✅ 正确用法
if (isLoading) return <FullPageSkeleton />;
if (isPlaceholderData) return <Table data={data} dimmed />;
return <Table data={data} />;
忘记 isPlaceholderData 的类型守卫
// ❌ placeholderData 可能是 undefined
const { data } = useQuery({
placeholderData: keepPreviousData,
});
data.name // TS 报错:data 可能是 undefined
// ✅ 用 isPlaceholderData 做类型收窄
if (data && !isPlaceholderData) {
data.name // 安全
}