sequenceDiagram
participant U as User
participant C as Cache
participant S as Server
U->>C: Request data
alt Cache has data
C->>U: Return cached (stale) instantly
C->>S: Fetch fresh in background
S-->>C: New data
C->>U: Update UI with fresh data
else Cache empty
C->>S: Fetch from server
S-->>C: Response
C->>U: Show data
end
Data Fetching Strategies: Beyond Basic Fetch
Every React app needs data. But how you fetch it determines your app’s perceived performance, reliability, and code maintainability.
This chapter explores SWR, TanStack Query, and advanced patterns like optimistic updates and infinite scroll.
The Problem with Naive Fetching
// 😰 Classic useEffect fetch
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => { setUser(data); setLoading(false); })
.catch(err => { setError(err); setLoading(false); });
}, [userId]);
// ... render states
}
Problems: - No caching (refetches on every mount) - No background updates - Race conditions possible - Duplicated boilerplate everywhere
TanStack Query: The Solution
TanStack Query (formerly React Query) handles caching, deduplication, background refetching, and more.
npm install @tanstack/react-querySetup
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient();
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
</QueryClientProvider>
);
}
Basic Usage
import { useQuery } from '@tanstack/react-query';
function UserProfile({ userId }) {
const { data: user, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json()),
});
if (isLoading) return <Skeleton />;
if (error) return <Error message={error.message} />;
return <Profile user={user} />;
}
- Automatic caching — Data is stored and reused
- Background refetching — Updates on window focus
- Request deduplication — Multiple components share one request
- Stale-while-revalidate — Show cached data immediately, update in background
The Caching Blueprint: Stop Refetching Unchanged Data
Without caching, every component mount triggers a network request. Navigate away and back? Refetch. Open a modal? Refetch. The same unchanged data, over and over.
Understanding Cache Timing
TanStack Query uses two key timers:
| Timer | Default | What It Controls |
|---|---|---|
| staleTime | 0 | How long data is considered “fresh” (no background refetch) |
| gcTime | 5 min | How long unused data stays in cache before garbage collection |
flowchart LR
F["Fresh"] -->|staleTime expires| S["Stale"]
S -->|Component unmounts| I["Inactive"]
I -->|gcTime expires| G["Garbage Collected"]
S -->|"Window focus / Mount"| R["Background Refetch"]
R --> F
style F fill:#22c55e
style S fill:#eab308
style G fill:#ef4444
Configure Cache for Your Data Type
// Global defaults - set once at app initialization
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes - data is fresh
gcTime: 1000 * 60 * 30, // 30 minutes - keep in cache
refetchOnWindowFocus: false, // Don't refetch on tab switch
retry: 2,
},
},
});
Per-Query Cache Control
// Static data that rarely changes (e.g., country list, categories)
const { data: countries } = useQuery({
queryKey: ['countries'],
queryFn: fetchCountries,
staleTime: Infinity, // NEVER stale - cache forever
gcTime: Infinity, // NEVER garbage collect
});
// User data - fresh for 1 minute
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: 1000 * 60, // Fresh for 1 minute
});
// Real-time data - always refetch
const { data: stockPrice } = useQuery({
queryKey: ['stock', symbol],
queryFn: () => fetchStockPrice(symbol),
staleTime: 0, // Always stale - always refetch
refetchInterval: 5000, // Poll every 5 seconds
});
The Query Key is the Cache Key
// These are DIFFERENT cache entries:
useQuery({ queryKey: ['user', 1] }); // User 1
useQuery({ queryKey: ['user', 2] }); // User 2
useQuery({ queryKey: ['user', 1] }); // ✅ Uses cached User 1!
// Structure keys for invalidation:
queryKey: ['posts'] // All posts
queryKey: ['posts', 'list'] // Post list
queryKey: ['posts', 'list', { page: 1 }] // Page 1
queryKey: ['posts', 'detail', postId] // Single post
// Invalidate all posts (list + details):
queryClient.invalidateQueries({ queryKey: ['posts'] });
Prefetching: Load Before the User Clicks
// In a list component - prefetch on hover
function PostList({ posts }) {
const queryClient = useQueryClient();
const prefetchPost = (postId: number) => {
queryClient.prefetchQuery({
queryKey: ['posts', 'detail', postId],
queryFn: () => fetchPost(postId),
staleTime: 1000 * 60 * 5, // Don't prefetch if already fresh
});
};
return (
<ul>
{posts.map(post => (
<li
key={post.id}
onMouseEnter={() => prefetchPost(post.id)} // Prefetch on hover!
>
<Link to={`/posts/${post.id}`}>{post.title}</Link>
</li>
))}
</ul>
);
}
Cache Data That Never Changes
For truly static data (app config, feature flags, dropdown options):
// Fetch once, cache forever
function useAppConfig() {
return useQuery({
queryKey: ['app-config'],
queryFn: fetchAppConfig,
staleTime: Infinity,
gcTime: Infinity,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
}
// Or create a reusable option preset
const STATIC_QUERY_OPTIONS = {
staleTime: Infinity,
gcTime: Infinity,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
} as const;
// Use it
useQuery({
queryKey: ['categories'],
queryFn: fetchCategories,
...STATIC_QUERY_OPTIONS,
});
Initial Data from Cache
Provide initial data while fresh data loads:
// Use cached list data as initial detail data
function PostDetail({ postId }) {
const { data } = useQuery({
queryKey: ['posts', 'detail', postId],
queryFn: () => fetchPost(postId),
initialData: () => {
// Check if we have this post in the list cache
const posts = queryClient.getQueryData(['posts', 'list']);
return posts?.find(p => p.id === postId);
},
initialDataUpdatedAt: () => {
// Use the list's last update time
return queryClient.getQueryState(['posts', 'list'])?.dataUpdatedAt;
},
});
}
Mutations: Updating Data
import { useMutation, useQueryClient } from '@tanstack/react-query';
function UpdateProfileForm({ user }) {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (newData) =>
fetch(`/api/users/${user.id}`, {
method: 'PATCH',
body: JSON.stringify(newData),
}).then(res => res.json()),
onSuccess: () => {
// Invalidate and refetch
queryClient.invalidateQueries({ queryKey: ['user', user.id] });
},
});
const handleSubmit = (formData) => {
mutation.mutate(formData);
};
return (
<form onSubmit={handleSubmit}>
{/* ... form fields */}
<button disabled={mutation.isPending}>
{mutation.isPending ? 'Saving...' : 'Save'}
</button>
</form>
);
}
Optimistic Updates
Update the UI before the server confirms, then roll back if it fails:
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['todos'] });
// Snapshot previous value
const previousTodos = queryClient.getQueryData(['todos']);
// Optimistically update
queryClient.setQueryData(['todos'], old =>
old.map(t => t.id === newTodo.id ? newTodo : t)
);
return { previousTodos };
},
onError: (err, newTodo, context) => {
// Roll back on error
queryClient.setQueryData(['todos'], context.previousTodos);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
Infinite Scroll
import { useInfiniteQuery } from '@tanstack/react-query';
import { useInView } from 'react-intersection-observer';
function InfiniteFeed() {
const { ref, inView } = useInView();
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam = 1 }) =>
fetch(`/api/posts?page=${pageParam}`).then(res => res.json()),
getNextPageParam: (lastPage) => lastPage.nextPage,
});
// Fetch next page when sentinel is visible
useEffect(() => {
if (inView && hasNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, fetchNextPage]);
return (
<>
{data?.pages.map(page =>
page.posts.map(post => <PostCard key={post.id} post={post} />)
)}
<div ref={ref}>
{isFetchingNextPage && <Spinner />}
</div>
</>
);
}
SWR vs TanStack Query
| Feature | SWR | TanStack Query |
|---|---|---|
| Bundle Size | Smaller | Larger |
| DevTools | Basic | Excellent |
| Mutations | Manual | Built-in |
| Infinite Queries | Supported | Excellent |
| Learning Curve | Lower | Moderate |
Recommendation: Use TanStack Query for complex apps, SWR for simpler needs.
Key Patterns
- Stale-While-Revalidate: Show cached data immediately, update in background
- Prefetching: Load data before the user navigates
- Polling: Refresh data on an interval for near-real-time updates
- Dependent Queries: Fetch B only after A completes
Data fetching is the nervous system of your app. Invest in a robust solution.