← Back to Lessons
  • cache

  • UX

  • Data Fetching

  • React.js

  • performance

  • TanStack Query

  • React Query

Data Fetching for Smooth UI with React Query

Cache and Staleness Strategies: staleTime and cacheTime

In modern applications, user experience quickly degrades when spinners constantly appear, the interface "jumps" while loading data, or users see blank screens while waiting. React Query (TanStack Query) allows us to create smooth interfaces where data updates intelligently in the background, without interrupting user interaction.

Cache and Staleness Strategies: staleTime and cacheTime

React Query maintains two fundamental concepts to control how and when data is refreshed:

  1. staleTime which defines how long data is considered "fresh". During this period, React Query will not automatically make a new request.
1const { data } = useQuery({ 2 queryKey: ['products'], 3 queryFn: fetchProducts, 4 staleTime: 5 * 60 * 1000, // 5 minutes 5});

With staleTime: 5 minutes, if the user navigates to another page and returns within that time, React Query will show cached data instantly without making a new request.

  1. cacheTime which controls how long data remains in memory after no components are observing it. Default is 5 minutes.
1const { data } = useQuery({ 2 queryKey: ['userProfile', userId], 3 queryFn: () => fetchUserProfile(userId), 4 staleTime: 2 * 60 * 1000, // Fresh for 2 minutes 5 cacheTime: 10 * 60 * 1000, // Cached for 10 minutes 6});

For data that changes infrequently (categories, configuration), use high staleTime (30-60 minutes). For dynamic data (activity feed), keep staleTime low but leverage cache for quick transitions.

keepPreviousData for Smooth Transitions

One of the most powerful patterns to eliminate "jank" is showing previous data while new data loads:

1function ProductList({ category }) { 2 const { data, isFetching } = useQuery({ 3 queryKey: ['products', category], 4 queryFn: () => fetchProductsByCategory(category), 5 keepPreviousData: true, 6 }); 7 8 return ( 9 <div className={isFetching ? 'opacity-50' : ''}> 10 {data?.products.map(product => ( 11 <ProductCard key={product.id} {...product} /> 12 ))} 13 </div> 14 ); 15}

When the user changes category, the previous list remains visible with slight opacity, avoiding empty loading states. The UI never "jumps" or shows spinners. Ideal use case: Pagination, filters, searches. The user maintains visual context while new data loads.

Request Optimization

  • Automatic De-duplication: React Query automatically de-duplicates identical requests made simultaneously:
1// Multiple components can call this at the same time 2function UserAvatar({ userId }) { 3 const { data } = useQuery({ 4 queryKey: ['user', userId], 5 queryFn: () => fetchUser(userId), 6 }); 7 8 return <img src={data?.avatar} alt={data?.name} />; 9} 10 11// If 10 components render simultaneously with the same userId, 12// only ONE HTTP request is made

This eliminates the need to manually manage which component is "responsible" for loading data.

  • Intelligent Background Refetch: React Query automatically refreshes data in key situations without interrupting the user:
1const { data } = useQuery({ 2 queryKey: ['notifications'], 3 queryFn: fetchNotifications, 4 staleTime: 30 * 1000, // Fresh for 30 seconds 5 refetchOnWindowFocus: true, // Refetch when returning to tab 6 refetchOnReconnect: true, // Refetch when connection recovers 7 refetchInterval: 60 * 1000, // Polling every minute 8});

Advanced pattern: Conditional refetch based on application state.

1const { data } = useQuery({ 2 queryKey: ['liveScores', matchId], 3 queryFn: () => fetchMatchScores(matchId), 4 refetchInterval: (data) => { 5 // If match is finished, stop polling 6 return data?.status === 'finished' ? false : 5000; 7 }, 8});
  • select: for Transforming and Deriving Data. The select option allows efficient data transformation with automatic memoization:
1function ActiveUsers() { 2 const { data: activeCount } = useQuery({ 3 queryKey: ['users'], 4 queryFn: fetchAllUsers, 5 select: (users) => users.filter(u => u.isActive).length, 6 staleTime: 5 * 60 * 1000, 7 }); 8 9 return <Badge>{activeCount} active users</Badge>; 10}

Advantages of select:

  • Only re-renders if the transformed result changes (not if other fields in original data change)
  • Multiple components can use the same query with different selectors
  • Raw data remains cached for other queries
1// Reusable selector 2const selectActiveUsers = (users) => 3 users.filter(u => u.isActive); 4 5// Component A needs only active users 6const { data: activeUsers } = useQuery({ 7 queryKey: ['users'], 8 queryFn: fetchAllUsers, 9 select: selectActiveUsers, 10}); 11 12// Component B needs all users 13const { data: allUsers } = useQuery({ 14 queryKey: ['users'], 15 queryFn: fetchAllUsers, 16}); 17 18// Only 1 HTTP request, 2 views of the data

States Without Blocking the UI

  • Intelligent Skeletons: Instead of centralized spinners, use skeletons that maintain visual structure.
1function ProductCard({ productId }) { 2 const { data, isLoading, isError } = useQuery({ 3 queryKey: ['product', productId], 4 queryFn: () => fetchProduct(productId), 5 }); 6 7 if (isLoading) { 8 return ( 9 <div className="card"> 10 <div className="skeleton-image h-48 bg-gray-200 animate-pulse" /> 11 <div className="skeleton-title h-6 bg-gray-200 animate-pulse mt-4" /> 12 <div className="skeleton-price h-4 bg-gray-200 animate-pulse mt-2 w-1/2" /> 13 </div> 14 ); 15 } 16 17 if (isError) { 18 return <ErrorCard message="Could not load product" />; 19 } 20 21 return ( 22 <div className="card"> 23 <img src={data.image} alt={data.name} /> 24 <h3>{data.name}</h3> 25 <p className="price">${data.price}</p> 26 </div> 27 ); 28}
  • Placeholders with Initial Data: To improve perceived speed, provide initial data while real data loads.
1const { data = DEFAULT_CATEGORIES } = useQuery({ 2 queryKey: ['categories'], 3 queryFn: fetchCategories, 4 placeholderData: DEFAULT_CATEGORIES, // Data until real data arrives 5});

Progressive Error Strategy

Handle errors without breaking the entire interface:

1function Dashboard() { 2 const stats = useQuery({ 3 queryKey: ['stats'], 4 queryFn: fetchStats, 5 retry: 3, // Retry 3 times 6 }); 7 8 const activity = useQuery({ 9 queryKey: ['activity'], 10 queryFn: fetchActivity, 11 retry: 1, 12 }); 13 14 return ( 15 <div className="dashboard"> 16 <StatsSection 17 data={stats.data} 18 isError={stats.isError} 19 /> 20 21 <ActivityFeed 22 data={activity.data} 23 isError={activity.isError} 24 /> 25 </div> 26 ); 27} 28 29function StatsSection({ data, isError }) { 30 if (isError) { 31 return ( 32 <div className="stats-error"> 33 <Icon name="alert" /> 34 <p>Statistics not available</p> 35 <button onClick={() => queryClient.refetchQueries(['stats'])}> 36 Retry 37 </button> 38 </div> 39 ); 40 } 41 42 return data ? <StatsDisplay data={data} /> : <StatsSkeleton />; 43}

Complete Pattern: Smooth List with Filters

Combining all techniques:

1function SmoothProductList() { 2 const [category, setCategory] = useState('all'); 3 const [search, setSearch] = useState(''); 4 5 const { data, isFetching, isError } = useQuery({ 6 queryKey: ['products', category, search], 7 queryFn: () => fetchProducts({ category, search }), 8 keepPreviousData: true, // No jumps when filtering 9 staleTime: 30 * 1000, // Cache 30s 10 select: (data) => ({ // Derive count 11 products: data.products, 12 count: data.products.length, 13 }), 14 }); 15 16 return ( 17 <div> 18 <SearchBar 19 value={search} 20 onChange={setSearch} 21 isSearching={isFetching} 22 /> 23 24 <CategoryTabs 25 selected={category} 26 onChange={setCategory} 27 /> 28 29 {isError ? ( 30 <ErrorState retry={() => queryClient.refetchQueries(['products'])} /> 31 ) : ( 32 <> 33 <ResultsCount count={data?.count} isUpdating={isFetching} /> 34 35 <div className={isFetching ? 'opacity-70' : ''}> 36 {data?.products.map(product => ( 37 <ProductCard key={product.id} {...product} /> 38 ))} 39 </div> 40 </> 41 )} 42 </div> 43 ); 44}

Conclusion

A smooth UI with React Query isn't about eliminating all loading indicators, but about keeping users informed without interrupting their flow. The keys are using cache intelligently with staleTime, maintaining visual context with keepPreviousData, transforming data efficiently with select, and replacing blocking spinners with skeletons and progressive states. The result is an application that feels fast, modern, and reliable.