Skip to main content

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

ProblemDescription
Race conditionsHard to guarantee order when multiple requests overlap
No cachingDuplicate requests when multiple components call the same URL
Difficult re-fetchingMust implement data refresh logic manually
Loading/error managementMust be implemented repeatedly in every component
SSR incompatibilityComplex 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

ItemTanStack QuerySWR
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 CurveMediumLow
Best ForComplex server stateSimple 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>