Data Fetching — useEffect Patterns, TanStack Query, SWR
useEffect + fetch Basic Pattern
The most basic data fetching approach. However, implementing it manually leads to many problems.
import { useState, useEffect } from 'react';
function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
async function fetchUsers() {
try {
setLoading(true);
setError(null);
const res = await fetch('/api/users');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (!cancelled) setUsers(data);
} catch (err) {
if (!cancelled) setError(err.message);
} finally {
if (!cancelled) setLoading(false);
}
}
fetchUsers();
return () => { cancelled = true; };
}, []);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
Problems with useEffect Data Fetching
| Problem | Description |
|---|---|
| Race conditions | Hard to guarantee order when multiple requests overlap |
| No caching | Duplicate requests when multiple components call the same URL |
| Difficult re-fetching | Must implement data refresh logic manually |
| Loading/error management | Must be implemented repeatedly in every component |
| SSR incompatibility | Complex handling needed for server rendering |
TanStack Query (React Query)
The de facto standard library for managing server state.
Installation and Setup
npm install @tanstack/react-query
// main.jsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // Stay fresh for 5 minutes
retry: 3, // Retry 3 times on failure
},
},
});
createRoot(document.getElementById('root')).render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
);
useQuery
import { useQuery } from '@tanstack/react-query';
async function fetchUsers() {
const res = await fetch('/api/users');
if (!res.ok) throw new Error('Failed to load user list.');
return res.json();
}
function UserList() {
const {
data: users,
isLoading,
isError,
error,
isFetching, // Background re-fetch in progress
refetch, // Manual refetch
} = useQuery({
queryKey: ['users'], // Cache key
queryFn: fetchUsers,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 10, // Delete cache after 10 minutes (formerly cacheTime)
});
if (isLoading) return <p>Loading...</p>;
if (isError) return <p>Error: {error.message}</p>;
return (
<div>
{isFetching && <span>Updating...</span>}
<ul>
{users.map(user => <li key={user.id}>{user.name}</li>)}
</ul>
<button onClick={() => refetch()}>Refresh</button>
</div>
);
}
Dynamic Queries (queryKey Dependencies)
function UserDetail({ userId }) {
const { data: user, isLoading } = useQuery({
queryKey: ['users', userId], // New request when userId changes
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
enabled: !!userId, // Only run when userId exists
});
if (isLoading) return <p>Loading...</p>;
return <h1>{user?.name}</h1>;
}
useMutation
import { useMutation, useQueryClient } from '@tanstack/react-query';
function CreateUserForm() {
const queryClient = useQueryClient();
const createUser = useMutation({
mutationFn: async (userData) => {
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData),
});
if (!res.ok) throw new Error('Failed to create user');
return res.json();
},
onSuccess: (newUser) => {
// Invalidate cache → automatic re-fetch
queryClient.invalidateQueries({ queryKey: ['users'] });
// Or add directly to cache (without re-fetching)
queryClient.setQueryData(['users'], (old) => [...(old ?? []), newUser]);
},
onError: (error) => {
alert(`Error: ${error.message}`);
},
});
const [name, setName] = useState('');
function handleSubmit(e) {
e.preventDefault();
createUser.mutate({ name, email: `${name}@example.com` });
}
return (
<form onSubmit={handleSubmit}>
<input value={name} onChange={e => setName(e.target.value)} />
<button type="submit" disabled={createUser.isPending}>
{createUser.isPending ? 'Creating...' : 'Add User'}
</button>
</form>
);
}
Optimistic Updates
const toggleTodo = useMutation({
mutationFn: (todo) =>
fetch(`/api/todos/${todo.id}`, {
method: 'PATCH',
body: JSON.stringify({ done: !todo.done }),
}),
onMutate: async (todo) => {
// Cancel any in-progress re-fetches
await queryClient.cancelQueries({ queryKey: ['todos'] });
// Snapshot of previous data
const previousTodos = queryClient.getQueryData(['todos']);
// Optimistically update cache
queryClient.setQueryData(['todos'], (old) =>
old.map(t => t.id === todo.id ? { ...t, done: !t.done } : t)
);
return { previousTodos };
},
onError: (err, todo, context) => {
// Restore previous data on error
queryClient.setQueryData(['todos'], context.previousTodos);
},
onSettled: () => {
// Sync latest data regardless of success or failure
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
SWR
A data fetching library developed by Vercel that uses the "Stale-While-Revalidate" strategy.
Installation
npm install swr
Basic Usage
import useSWR from 'swr';
const fetcher = (url) => fetch(url).then(r => r.json());
function UserProfile({ userId }) {
const { data: user, error, isLoading, mutate } = useSWR(
userId ? `/api/users/${userId}` : null,
fetcher,
{
revalidateOnFocus: true, // Revalidate when tab gains focus
revalidateOnReconnect: true, // Revalidate on network reconnect
refreshInterval: 30000, // Auto-refresh every 30 seconds
}
);
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error occurred</p>;
return (
<div>
<h1>{user.name}</h1>
<button onClick={() => mutate()}>Refresh</button>
</div>
);
}
Mutation (SWR)
import useSWRMutation from 'swr/mutation';
async function createUser(url, { arg }) {
const res = await fetch(url, {
method: 'POST',
body: JSON.stringify(arg),
});
return res.json();
}
function NewUserForm() {
const { trigger, isMutating } = useSWRMutation('/api/users', createUser);
async function handleSubmit(e) {
e.preventDefault();
const formData = new FormData(e.target);
await trigger({ name: formData.get('name') });
}
return (
<form onSubmit={handleSubmit}>
<input name="name" required />
<button disabled={isMutating}>
{isMutating ? 'Processing...' : 'Add'}
</button>
</form>
);
}
TanStack Query vs SWR Comparison
| Item | TanStack Query | SWR |
|---|---|---|
| Bundle Size | ~50KB | ~15KB |
| Mutation Support | ✅ Full useMutation | ⚠️ useSWRMutation (limited) |
| Optimistic Updates | ✅ Built-in | ⚠️ Requires manual implementation |
| Infinite Scroll | ✅ useInfiniteQuery | ✅ useSWRInfinite |
| DevTools | ✅ Powerful UI | ❌ None |
| Query Cancellation | ✅ | ❌ |
| Learning Curve | Medium | Low |
| Best For | Complex server state | Simple READ-focused use |
Practical Example: Infinite Scroll
import { useInfiniteQuery } from '@tanstack/react-query';
import { useRef, useEffect } from 'react';
async function fetchPosts({ pageParam = 1 }) {
const res = await fetch(`/api/posts?page=${pageParam}&limit=10`);
const data = await res.json();
return { posts: data.posts, nextPage: data.hasMore ? pageParam + 1 : undefined };
}
function InfinitePostList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
getNextPageParam: (lastPage) => lastPage.nextPage,
initialPageParam: 1,
});
const loadMoreRef = useRef(null);
// Implement infinite scroll with IntersectionObserver
useEffect(() => {
const observer = new IntersectionObserver(
entries => {
if (entries[0].isIntersecting && hasNextPage) {
fetchNextPage();
}
},
{ threshold: 0.1 }
);
if (loadMoreRef.current) observer.observe(loadMoreRef.current);
return () => observer.disconnect();
}, [fetchNextPage, hasNextPage]);
if (isLoading) return <p>Loading...</p>;
const posts = data.pages.flatMap(page => page.posts);
return (
<div>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</article>
))}
<div ref={loadMoreRef}>
{isFetchingNextPage ? 'Loading more...' : ''}
{!hasNextPage ? 'All posts have been loaded.' : ''}
</div>
</div>
);
}
Pro Tips
1. Structure queryKey as an Array
// ✅ Hierarchical queryKey structure
queryKey: ['users'] // User list
queryKey: ['users', userId] // Specific user
queryKey: ['users', userId, 'posts'] // Posts by specific user
queryKey: ['users', { active: true }] // Include filter conditions
2. Custom Query Hook Pattern
// Extract into reusable custom hook
function useUsers(options = {}) {
return useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(r => r.json()),
...options,
});
}
// Usage
const { data: users } = useUsers({ staleTime: Infinity });
3. Using Error Boundaries and Suspense
function UserList() {
const { data } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
throwOnError: true, // Supports Suspense + ErrorBoundary
suspense: true,
});
return <ul>{data.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
// Usage
<ErrorBoundary fallback={<ErrorPage />}>
<Suspense fallback={<Skeleton />}>
<UserList />
</Suspense>
</ErrorBoundary>