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>