Skip to main content

Placeholder Data in React Query: Instant-Feeling Lists

Placeholder data and skeleton loaders make slow networks feel fast by showing a UI outline while data loads. React Query's placeholderData option presets the cache with dummy content, and skeleton components mimic the real layout with animated gray boxes. Psychologically, users perceive a 1-second load with skeleton 37% faster than a blank spinner, according to Nielsen Norman Group research.

Placeholder data is the UX multiplier that separates polished apps from rough ones. Combined with prefetching, it creates an illusion of instant responsiveness.

Using placeholderData in React Query

The placeholderData option seeds the cache before the real fetch completes:

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

async function fetchProduct(id) {
const response = await fetch(`/api/products/${id}`);
return response.json();
}

export function ProductDetail({ productId }) {
const { data = {}, isLoading } = useQuery({
queryKey: ['product', productId],
queryFn: () => fetchProduct(productId),
placeholderData: {
id: productId,
name: 'Loading product...',
price: 0,
description: '...',
},
});

return (
`<div>`
`<h1>{data.name}`</h1>`
`<p>${data.price}`</p>`
`<p>{data.description}`</p>`
{isLoading && `<p>`(Loading real data...)`</p>`}
`</div>`
);
}

The placeholder data displays immediately while the real fetch happens. The UI doesn't jump or flicker.

Placeholder Data from Previous Queries

A common pattern: when navigating from a list to detail, show the list item as placeholder until detail loads:

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

async function fetchProductDetail(id) {
const response = await fetch(`/api/products/${id}`);
return response.json();
}

export function ProductDetail({ productId }) {
const queryClient = useQueryClient();

const { data = {} } = useQuery({
queryKey: ['product', productId],
queryFn: () => fetchProductDetail(productId),
placeholderData: () => {
// Try to find a cached list item for this product
const cachedList = queryClient.getQueryData(['products']);
return cachedList?.items?.find((p) => p.id === parseInt(productId));
},
});

return `<div>{data.name}`</div>`;
}

This pattern is powerful: if the user just viewed the product in a list, they see it immediately with detail loading in the background.

Skeleton Loaders: Animated Placeholders

Skeleton loaders are animated gray boxes matching your layout. They're more sophisticated than text placeholders:

// Skeleton.jsx
export function SkeletonProduct() {
return (
`<div style={{ animation: 'pulse 1.5s ease-in-out infinite' }}>`
`<div`
style={{
width: '100%',
height: '200px',
background: '#e0e0e0',
borderRadius: '8px',
marginBottom: '1rem',
}}
`/>`
`<div`
style={{
width: '60%',
height: '24px',
background: '#e0e0e0',
borderRadius: '4px',
marginBottom: '0.5rem',
}}
`/>`
`<div`
style={{
width: '40%',
height: '20px',
background: '#e0e0e0',
borderRadius: '4px',
}}
`/>`
`</div>`
);
}

// CSS
const style = document.createElement('style');
style.textContent = `
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
`;
document.head.appendChild(style);

Or use a library like react-loading-skeleton:

npm install react-loading-skeleton
import Skeleton from 'react-loading-skeleton';
import 'react-loading-skeleton/dist/skeleton.css';

export function ProductCard({ productId, isLoading }) {
return (
`<div>`
{isLoading ? (
`<>`
`<Skeleton height={200} />`
`<Skeleton count={2} />`
`</>`
) : (
`<div>`
{/* real content */}
`</div>`
)}
`</div>`
);
}

Combining Placeholder Data with Skeleton Loaders

For the best UX, use both:

import { useQuery } from '@tanstack/react-query';
import Skeleton from 'react-loading-skeleton';

async function fetchPost(id) {
const response = await fetch(`/api/posts/${id}`);
return response.json();
}

export function PostDetail({ postId }) {
const { data, isLoading } = useQuery({
queryKey: ['post', postId],
queryFn: () => fetchPost(postId),
placeholderData: {
id: postId,
title: 'Loading...',
content: 'Loading...',
author: 'Loading...',
},
});

return (
`<article>`
{isLoading ? (
`<>`
`<h1>`{`<Skeleton />`}`</h1>`
`<p>`{`<Skeleton count={3} />`}`</p>`
`</>`
) : (
`<>`
`<h1>{data.title}`</h1>`
`<p>{data.content}`</p>`
`</>`
)}
`</article>`
);
}

When loading, show skeletons. When data arrives, show real content. The placeholder fills the gap if the network is very slow.

Placeholder Data for Infinite Queries

With useInfiniteQuery, provide placeholder pages:

import { useInfiniteQuery } from '@tanstack/react-query';

export function InfinitePostList() {
const { data = { pages: [] }, fetchNextPage, hasNextPage } =
useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam = 0 }) =>
fetch(`/api/posts?offset=${pageParam}&limit=20`).then((r) =>
r.json()
),
placeholderData: {
pages: [
{
items: Array(20).fill({ id: 0, title: '...' }),
total: 0,
},
],
pageParams: [0],
},
getNextPageParam: (lastPage) =>
lastPage.items.length === 20 ? lastPage.nextOffset : undefined,
});

return (
`<ul>`
{data.pages.map((page) =>
page.items.map((post) => (
`<li key={post.id}>{post.title}`</li>`
))
)}
`</ul>`
);
}

The placeholder shows 20 dummy items instantly, then swaps them for real data.

Animated Loading States

Use CSS animations to make skeleton loaders engaging:

// Skeleton with animated gradient
export function AnimatedSkeleton() {
return (
`<div`
style={{
background: 'linear-gradient(90deg, #f0f0f0, #e0e0e0, #f0f0f0)',
backgroundSize: '200% 100%',
animation: 'shimmer 1.5s infinite',
height: '100px',
borderRadius: '4px',
}}
`/>`
);
}

// CSS
const style = document.createElement('style');
style.textContent = `
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
`;
document.head.appendChild(style);

Performance Considerations

Placeholder data is stored in React Query's cache, which persists in memory. For large lists, this can waste memory. Unset the placeholder after the query succeeds:

useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
placeholderData: initialPlaceholder,
select: (data) => {
// After real data arrives, forget the placeholder
queryClient.setQueryData(['products'], data);
return data;
},
})

Or use keepPreviousData: true instead: it shows the previous page's data while fetching the new one, no placeholder needed.

Key Takeaways

  • Placeholder data makes slow networks feel fast by showing content immediately.
  • Skeleton loaders are animated outlines; they reduce perceived load time by 37%.
  • Use placeholderData from previous queries for seamless transitions.
  • Combine placeholder data with skeleton loaders for optimal UX.
  • Keep placeholder data lightweight; don't cache full datasets.

Frequently Asked Questions

Should placeholder data match the real data shape exactly?

Yes. If real data has { name, price, image }, placeholder should too. Mismatches cause render errors or layout shift.

Can I show different placeholder data for different users?

Yes. Compute placeholderData dynamically based on user preferences or cached data. React Query re-computes it on every query.

Do skeleton loaders hurt Core Web Vitals?

No, if they're simple gray boxes. Heavy animations (multiple shadows, gradients) increase CPU usage. Keep them lightweight.

Should I prefetch and show placeholder data together?

Yes. Prefetch on hover, show placeholder on click, real data loads silently. This triple approach is unbeatable.

Further Reading