cache
UX
Data Fetching
React.js
performance
TanStack Query
React Query
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.
staleTime and cacheTimeReact Query maintains two fundamental concepts to control how and when data is refreshed:
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});
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 TransitionsOne 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.
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.
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:
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
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}
1const { data = DEFAULT_CATEGORIES } = useQuery({ 2 queryKey: ['categories'], 3 queryFn: fetchCategories, 4 placeholderData: DEFAULT_CATEGORIES, // Data until real data arrives 5});
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}
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}
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.