placeholderData 与 keepPreviousData

切换查询参数时,TanStack Query 默认会进入 loading 状态。placeholderData 和 keepPreviousData 可以在新数据加载期间保留旧数据,避免 UI 闪烁。

#type / howto #status / evergreen #tech / dev / frontend #resource / react

[!info] related notes

placeholderData 与 keepPreviousData

核心问题

const [page, setPage] = useState(1);
const { data } = useQuery({
  queryKey: ['users', page],
  queryFn: () => fetchUsers(page),
});

page 从 1 变到 2 时:

  1. queryKey 变化,触发新请求
  2. 旧缓存(page 1)不再匹配新 key
  3. data 变成 undefined
  4. UI 显示 loading → 新数据到达 → UI 显示数据

用户看到一次闪烁:内容消失 → loading → 内容出现。

解法一:keepPreviousData(v4)

const { data, isPlaceholderData } = useQuery({
  queryKey: ['users', page],
  queryFn: () => fetchUsers(page),
  keepPreviousData: true,
});

切换时 data 保持为旧数据,直到新数据到达。isPlaceholderDatatrue 表示当前显示的是旧数据。

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  // 安全
}
创建于 2026/7/3 更新于 2026/7/15