Fetching Data in React: The Right Way — fetch, axios, React Query, SWR & More Compared
If you've built anything in React beyond a todo app, you've had to fetch data from somewhere. And if you've been doing this for a while, you've probably noticed the landscape shifting under your feet every couple of years. Class component lifecycle methods gave way to useEffect, which gave way to dedicated data-fetching libraries, and now server components are changing the game again.
This guide walks through every major approach to data fetching in React, with real code, honest trade-offs, and a clear decision framework at the end so you can stop second-guessing your choices.
The Evolution of Data Fetching in React
Let's zoom out for a second. How did we get here?
Timeline of React Data Fetching
================================
2015 2019 2020 2022 2024+
| | | | |
v v v | v
Class Hooks React Query v Server
Components (useEffect) & SWR Suspense Components
(stable) (RSC + Next.js)
componentDidMount -> useEffect -> useQuery -> async components
^ ^ ^ ^
| | | |
Manual state Still manual Automatic No client
+ lifecycle but cleaner caching, state needed
management API dedup, retry for fetches
Each generation solved problems the previous one created. Class components were verbose. useEffect was cleaner but introduced subtle bugs (race conditions, stale closures). Libraries like React Query abstracted those problems away. And now server components let you skip the client entirely for many data-fetching scenarios.
Let's dig into each approach.
1. Native fetch API in React
The simplest approach. No dependencies. Just the browser's built-in fetch.
Basic Pattern
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController(); // For cleanup
async function fetchUser() {
try {
setLoading(true);
const response = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
setUser(data);
} catch (err) {
if (err.name !== 'AbortError') {
setError(err.message);
}
} finally {
setLoading(false);
}
}
fetchUser();
return () => controller.abort(); // Cleanup on unmount or re-render
}, [userId]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return <div>{user.name}</div>;
}
What fetch Does Well
- Zero dependencies — it's built into every modern browser and Node.js 18+
- Streaming support — you can read response bodies as streams
- Full control — nothing is abstracted away, you see exactly what's happening
What fetch Does Poorly
-
No automatic JSON parsing — you always need
.json()(unlike axios) -
Doesn't throw on HTTP errors — a 404 response is a "successful" fetch. You have to check
response.okyourself - No request/response interceptors — want to attach an auth token to every request? You'll write a wrapper
-
No built-in timeout — you need
AbortController+setTimeout - No retry logic — you build it yourself
- No caching — every render triggers a new request unless you manage it
Adding a Timeout
async function fetchWithTimeout(url, options = {}, timeout = 5000) {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
});
return response;
} finally {
clearTimeout(id);
}
}
The fetch API is fine for quick prototypes or when you truly need minimal overhead. But for production apps, you'll almost always want something more.
2. Axios — The Reliable Workhorse
Axios has been around since 2014 and it's still going strong. It wraps HTTP requests in a developer-friendly API that fixes most of fetch's annoyances.
Setup and Basic Usage
npm install axios
import axios from 'axios';
// Create a configured instance
const api = axios.create({
baseURL: 'https://api.example.com',
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});
// GET request
const { data } = await api.get('/users/123');
// POST request
const { data: newUser } = await api.post('/users', {
name: 'Jane Doe',
email: 'jane@example.com',
});
Interceptors — The Killer Feature
Interceptors let you hook into every request or response globally. This is huge for auth, logging, and error handling.
// Request interceptor — attach auth token
api.interceptors.request.use((config) => {
const token = localStorage.getItem('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Response interceptor — handle token refresh
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const { data } = await axios.post('/auth/refresh', {
refreshToken: localStorage.getItem('refreshToken'),
});
localStorage.setItem('accessToken', data.accessToken);
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
return api(originalRequest);
} catch (refreshError) {
// Redirect to login
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
Axios in a React Component
function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const source = axios.CancelToken.source();
api.get('/users', { cancelToken: source.token })
.then(({ data }) => setUsers(data))
.catch((err) => {
if (!axios.isCancel(err)) {
console.error(err);
}
})
.finally(() => setLoading(false));
return () => source.cancel('Component unmounted');
}, []);
// render...
}
Why People Love Axios
- Automatic JSON parsing (request and response)
- Throws on HTTP errors by default (no
response.okchecks) - Request/response interceptors
- Built-in request cancellation
- Progress tracking for uploads/downloads
- Works identically in browser and Node.js
Why Some People Are Moving Away
- It's another dependency (~13KB gzipped)
- The
fetchAPI has gotten better over time - Libraries like React Query handle the HTTP layer differently
- The
CancelTokenAPI was deprecated in favor ofAbortControllersupport
3. React Query (TanStack Query) — The Recommended Approach
If you're building a React app that talks to a server, React Query is almost certainly what you should be using. It's not just a data-fetching library; it's a server state management solution.
The core insight: server state and client state are fundamentally different things. Server state is remote, asynchronous, shared, and can become stale. Treating it the same as local UI state (like a modal being open) is a recipe for bugs.
Installation and Setup
npm install @tanstack/react-query @tanstack/react-query-devtools
// app.jsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
retry: 3,
refetchOnWindowFocus: true,
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<Router />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
useQuery — Reading Data
import { useQuery } from '@tanstack/react-query';
function UserProfile({ userId }) {
const {
data: user,
isLoading,
isError,
error,
isFetching, // true during background refetches
isStale,
} = useQuery({
queryKey: ['users', userId], // Unique cache key
queryFn: () => api.get(`/users/${userId}`).then(res => res.data),
staleTime: 1000 * 60 * 5, // Consider fresh for 5 min
gcTime: 1000 * 60 * 30, // Keep in cache for 30 min
enabled: !!userId, // Don't run if userId is falsy
});
if (isLoading) return <Skeleton />;
if (isError) return <ErrorMessage error={error} />;
return (
<div>
<h1>{user.name}</h1>
{isFetching && <SmallSpinner />} {/* Background refetch indicator */}
</div>
);
}
Query Keys — The Cache Backbone
Query keys are how React Query identifies and caches data. They're arrays, and the structure matters.
// These are all DIFFERENT cache entries:
useQuery({ queryKey: ['users'] }) // All users
useQuery({ queryKey: ['users', 'list'] }) // User list
useQuery({ queryKey: ['users', userId] }) // Single user
useQuery({ queryKey: ['users', { status: 'active' }] }) // Filtered users
// Query key hierarchy enables smart invalidation:
queryClient.invalidateQueries({ queryKey: ['users'] })
// ^ Invalidates ALL queries starting with 'users'
Query Key Hierarchy
====================
['users']
|
+-- ['users', 'list']
| +-- ['users', 'list', { page: 1 }]
| +-- ['users', 'list', { page: 2 }]
|
+-- ['users', 42]
+-- ['users', 99]
invalidateQueries(['users']) --> invalidates EVERYTHING above
invalidateQueries(['users', 'list'])--> invalidates list + pages only
invalidateQueries(['users', 42]) --> invalidates only user 42
useMutation — Writing Data
import { useMutation, useQueryClient } from '@tanstack/react-query';
function CreateUserForm() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (newUser) => api.post('/users', newUser),
onMutate: async (newUser) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['users'] });
// Snapshot previous value
const previousUsers = queryClient.getQueryData(['users']);
// Optimistically update
queryClient.setQueryData(['users'], (old) => [
...old,
{ ...newUser, id: 'temp-id' },
]);
return { previousUsers }; // Context for rollback
},
onError: (err, newUser, context) => {
// Rollback on error
queryClient.setQueryData(['users'], context.previousUsers);
toast.error('Failed to create user');
},
onSuccess: () => {
toast.success('User created!');
},
onSettled: () => {
// Refetch regardless of success/error
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
const handleSubmit = (formData) => {
mutation.mutate(formData);
};
return (
<form onSubmit={handleSubmit}>
{/* form fields */}
<button
type="submit"
disabled={mutation.isPending}
>
{mutation.isPending ? 'Creating...' : 'Create User'}
</button>
</form>
);
}
Infinite Queries — Pagination and Infinite Scroll
import { useInfiniteQuery } from '@tanstack/react-query';
function InfiniteFeed() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['posts', 'infinite'],
queryFn: ({ pageParam = 1 }) =>
api.get(`/posts?page=${pageParam}&limit=20`).then(res => res.data),
getNextPageParam: (lastPage, allPages) => {
return lastPage.hasMore ? allPages.length + 1 : undefined;
},
initialPageParam: 1,
});
const allPosts = data?.pages.flatMap(page => page.items) ?? [];
return (
<div>
{allPosts.map(post => (
<PostCard key={post.id} post={post} />
))}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage
? 'Loading more...'
: hasNextPage
? 'Load More'
: 'No more posts'}
</button>
</div>
);
}
Prefetching — Make It Feel Instant
// Prefetch on hover
function UserLink({ userId }) {
const queryClient = useQueryClient();
const prefetchUser = () => {
queryClient.prefetchQuery({
queryKey: ['users', userId],
queryFn: () => api.get(`/users/${userId}`).then(res => res.data),
staleTime: 1000 * 60 * 5,
});
};
return (
<Link
to={`/users/${userId}`}
onMouseEnter={prefetchUser}
>
View Profile
</Link>
);
}
// Prefetch in route loaders (React Router)
const router = createBrowserRouter([
{
path: '/users/:id',
loader: ({ params }) => {
queryClient.prefetchQuery({
queryKey: ['users', params.id],
queryFn: () => api.get(`/users/${params.id}`).then(res => res.data),
});
return null;
},
element: <UserProfile />,
},
]);
React Query DevTools
One of React Query's best features. It gives you a panel showing every query in your cache, its status, when it was last fetched, and lets you manually trigger refetches or clear the cache. Invaluable during development.
4. SWR by Vercel — Stale-While-Revalidate
SWR takes its name from the HTTP cache invalidation strategy stale-while-revalidate. The idea: return cached (stale) data first, then fetch in the background, then swap in the fresh data.
Basic Usage
npm install swr
import useSWR from 'swr';
const fetcher = (url) => fetch(url).then(res => res.json());
function UserProfile({ userId }) {
const { data, error, isLoading, isValidating, mutate } = useSWR(
`/api/users/${userId}`,
fetcher
);
if (isLoading) return <Skeleton />;
if (error) return <div>Failed to load</div>;
return (
<div>
<h1>{data.name}</h1>
{isValidating && <SmallSpinner />}
<button onClick={() => mutate()}>Refresh</button>
</div>
);
}
Global Configuration
import { SWRConfig } from 'swr';
function App() {
return (
<SWRConfig
value={{
fetcher: (url) => api.get(url).then(res => res.data),
revalidateOnFocus: true,
revalidateOnReconnect: true,
dedupingInterval: 2000,
errorRetryCount: 3,
}}
>
<Router />
</SWRConfig>
);
}
SWR vs React Query — Honest Comparison
SWR is simpler and lighter. React Query is more powerful and opinionated. Here's how they differ in practice:
// SWR — the key IS the URL, simpler mental model
const { data } = useSWR('/api/users', fetcher);
// React Query — separate key and function, more flexible
const { data } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});
SWR wins on simplicity. React Query wins on features (mutations, infinite queries, query invalidation patterns, devtools). For most production apps, React Query is the better choice. For simpler apps or when you want minimal API surface, SWR is great.
5. useEffect Pitfalls — The Bugs You'll Hit
Before we move on, let's talk about the problems you'll encounter if you do data fetching with raw useEffect. These aren't hypothetical; they happen all the time.
Race Condition
// BUG: Race condition
function SearchResults({ query }) {
const [results, setResults] = useState([]);
useEffect(() => {
// If query changes rapidly, responses arrive out of order.
// Search for "react" then "vue" — the "react" response might
// arrive AFTER the "vue" response, showing wrong results.
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(data => setResults(data));
}, [query]);
return <ResultsList results={results} />;
}
// FIX: Use a cleanup flag
function SearchResults({ query }) {
const [results, setResults] = useState([]);
useEffect(() => {
let cancelled = false;
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(data => {
if (!cancelled) {
setResults(data);
}
});
return () => { cancelled = true; };
}, [query]);
return <ResultsList results={results} />;
}
Memory Leak on Unmount
// BUG: Setting state on unmounted component
useEffect(() => {
fetch('/api/data')
.then(res => res.json())
.then(data => setData(data)); // Component might be unmounted!
}, []);
// FIX: AbortController
useEffect(() => {
const controller = new AbortController();
fetch('/api/data', { signal: controller.signal })
.then(res => res.json())
.then(data => setData(data))
.catch(err => {
if (err.name !== 'AbortError') throw err;
});
return () => controller.abort();
}, []);
The Double-Fetch in Strict Mode
React 18's Strict Mode intentionally mounts, unmounts, and remounts components in development. If your useEffect fires a fetch, you'll see two requests. This is by design — it's exposing bugs in your code. The fix is proper cleanup (AbortController) or using a library that handles it.
This is exactly why libraries like React Query exist. They handle all these edge cases internally so you don't have to think about them.
6. React Suspense + Server Components
This is the newest paradigm shift and it's a big one, especially in Next.js.
Suspense for Data Fetching
import { Suspense } from 'react';
function App() {
return (
<Suspense fallback={<Skeleton />}>
<UserProfile userId={42} />
</Suspense>
);
}
The component "suspends" while loading, and React shows the fallback. No loading state management. No isLoading boolean. The component either has data or it doesn't.
Server Components (Next.js App Router)
This is the real game-changer. Server Components can be async functions that fetch data directly — no hooks, no state, no client-side waterfall.
// app/users/[id]/page.tsx — This runs on the SERVER
async function UserPage({ params }) {
const user = await fetch(`https://api.example.com/users/${params.id}`, {
next: { revalidate: 3600 }, // Cache for 1 hour
}).then(res => res.json());
const posts = await fetch(`https://api.example.com/users/${params.id}/posts`, {
next: { tags: ['user-posts'] }, // Tag-based revalidation
}).then(res => res.json());
return (
<div>
<h1>{user.name}</h1>
<PostList posts={posts} />
</div>
);
}
Parallel Data Fetching with Server Components
// Avoid waterfalls — fetch in parallel
async function Dashboard() {
// These all start simultaneously
const [user, stats, notifications] = await Promise.all([
getUser(),
getStats(),
getNotifications(),
]);
return (
<div>
<UserCard user={user} />
<StatsPanel stats={stats} />
<NotificationFeed notifications={notifications} />
</div>
);
}
Streaming with Suspense Boundaries
// The page shell loads immediately, each section streams in
async function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<StatsSkeleton />}>
<StatsPanel /> {/* Streams in when ready */}
</Suspense>
<Suspense fallback={<FeedSkeleton />}>
<ActivityFeed /> {/* Streams in independently */}
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<AnalyticsChart /> {/* Streams in independently */}
</Suspense>
</div>
);
}
Server Component Streaming
============================
Browser receives: What the user sees:
1. HTML shell --> [Header] [Dashboard Title]
[Skeleton] [Skeleton] [Skeleton]
2. Stats chunk --> [Header] [Dashboard Title]
[ Stats ] [Skeleton] [Skeleton]
3. Feed chunk --> [Header] [Dashboard Title]
[ Stats ] [ Feed ] [Skeleton]
4. Chart chunk --> [Header] [Dashboard Title]
[ Stats ] [ Feed ] [ Chart ]
Each section arrives independently — no waterfalls!
7. Custom Hooks for Data Fetching
If you're not using React Query but want reusable patterns, custom hooks help.
Generic useFetch Hook
function useFetch(url, options = {}) {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const controller = new AbortController();
let cancelled = false;
async function doFetch() {
try {
setLoading(true);
setError(null);
const response = await fetch(url, {
...options,
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const json = await response.json();
if (!cancelled) {
setData(json);
}
} catch (err) {
if (!cancelled && err.name !== 'AbortError') {
setError(err);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
doFetch();
return () => {
cancelled = true;
controller.abort();
};
}, [url]);
return { data, error, loading };
}
// Usage
function UserList() {
const { data: users, error, loading } = useFetch('/api/users');
// ...
}
useApi Hook with Caching
const cache = new Map();
function useApi(key, fetcher, options = {}) {
const { cacheTime = 60000, enabled = true } = options;
const [state, setState] = useState(() => {
const cached = cache.get(key);
if (cached && Date.now() - cached.timestamp < cacheTime) {
return { data: cached.data, error: null, loading: false };
}
return { data: null, error: null, loading: true };
});
useEffect(() => {
if (!enabled) return;
const controller = new AbortController();
fetcher({ signal: controller.signal })
.then(data => {
cache.set(key, { data, timestamp: Date.now() });
setState({ data, error: null, loading: false });
})
.catch(error => {
if (error.name !== 'AbortError') {
setState({ data: null, error, loading: false });
}
});
return () => controller.abort();
}, [key, enabled]);
return state;
}
Honestly though, once you start adding caching, deduplication, retry logic, and background refetching to a custom hook, you've just rebuilt React Query — poorly. Use the library.
8. Error Handling Patterns
Error Boundaries
import { Component } from 'react';
class ErrorBoundary extends Component {
state = { hasError: false, error: null };
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
console.error('Error caught by boundary:', error, errorInfo);
// Send to error tracking service
reportError(error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
<div className="error-fallback">
<h2>Something went wrong</h2>
<p>{this.state.error?.message}</p>
<button onClick={() => this.setState({ hasError: false })}>
Try Again
</button>
</div>
);
}
return this.props.children;
}
}
// Usage
function App() {
return (
<ErrorBoundary>
<Suspense fallback={<Loading />}>
<Dashboard />
</Suspense>
</ErrorBoundary>
);
}
Retry with Exponential Backoff (React Query)
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 3,
retryDelay: (attemptIndex) => Math.min(
1000 * 2 ** attemptIndex, // 1s, 2s, 4s
30000 // Max 30 seconds
),
},
},
});
Per-Query Error Handling
const { data, error } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
throwOnError: (error) => {
// Only throw to error boundary for 5xx errors
return error.response?.status >= 500;
},
retry: (failureCount, error) => {
// Don't retry 4xx errors
if (error.response?.status < 500) return false;
return failureCount < 3;
},
});
9. Loading State Patterns
Don't just show a spinner. Make your loading states informative and pleasant.
Skeleton UI
function UserCardSkeleton() {
return (
<div className="user-card">
<div className="skeleton skeleton-avatar" />
<div className="skeleton skeleton-text w-3/4" />
<div className="skeleton skeleton-text w-1/2" />
</div>
);
}
function UserList() {
const { data: users, isLoading } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});
if (isLoading) {
return (
<div>
{Array.from({ length: 5 }).map((_, i) => (
<UserCardSkeleton key={i} />
))}
</div>
);
}
return users.map(user => <UserCard key={user.id} user={user} />);
}
Progressive Loading with placeholderData
function UserProfile({ userId }) {
const { data, isPlaceholderData } = useQuery({
queryKey: ['users', userId],
queryFn: () => fetchUser(userId),
placeholderData: (previousData) => previousData,
// ^ Keep showing previous user's data while new user loads
});
return (
<div style={{ opacity: isPlaceholderData ? 0.5 : 1 }}>
<h1>{data?.name}</h1>
</div>
);
}
10. The Big Comparison Table
| Feature | fetch |
Axios | React Query | SWR | Server Components |
|---|---|---|---|---|---|
| Bundle size | 0 KB | ~13 KB | ~39 KB | ~12 KB | 0 KB (server) |
| Caching | Manual | Manual | Automatic | Automatic | HTTP cache / ISR |
| Deduplication | No | No | Yes | Yes | N/A |
| Retry logic | Manual | Manual | Built-in | Built-in | Manual |
| Devtools | No | No | Excellent | Limited | N/A |
| SSR support | Manual | Manual | Excellent | Good | Native |
| Optimistic updates | Manual | Manual | Built-in | Manual | N/A |
| Infinite queries | Manual | Manual | Built-in | Built-in | Manual |
| Mutations | Manual | Manual | Built-in | useSWRMutation |
Server Actions |
| Background refetch | No | No | Yes | Yes | Revalidation |
| Type safety | Basic | Good | Excellent | Good | Full |
| Learning curve | Low | Low | Medium | Low | Medium-High |
| Race conditions | You handle | You handle | Handled | Handled | N/A |
| Error boundaries | Manual | Manual | Supported | Supported | Supported |
| Polling | Manual | Manual | Built-in | Built-in | N/A |
| Prefetching | No | No | Yes | Yes | <Link prefetch> |
| Offline support | No | No | Yes | Limited | No |
11. Best Practices and Anti-Patterns
Do This
// 1. Colocate queries with the components that use them
function UserAvatar({ userId }) {
const { data } = useQuery({
queryKey: ['users', userId, 'avatar'],
queryFn: () => fetchUserAvatar(userId),
});
return <img src={data?.url} />;
}
// 2. Use query key factories
const userKeys = {
all: () => ['users'],
lists: () => [...userKeys.all(), 'list'],
list: (filters) => [...userKeys.lists(), filters],
details:() => [...userKeys.all(), 'detail'],
detail: (id) => [...userKeys.details(), id],
};
// Now invalidation is clean:
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
// 3. Separate your API layer
// api/users.js
export const usersApi = {
getAll: (filters) => api.get('/users', { params: filters }),
getById: (id) => api.get(`/users/${id}`),
create: (data) => api.post('/users', data),
update: (id, data) => api.put(`/users/${id}`, data),
delete: (id) => api.delete(`/users/${id}`),
};
Don't Do This
// 1. DON'T fetch in useEffect without cleanup
useEffect(() => {
fetch('/api/data').then(r => r.json()).then(setData);
}, []); // No cleanup = race conditions + memory leaks
// 2. DON'T put the entire response object in state
const [response, setResponse] = useState(null);
// Instead, extract what you need: setUser(response.data.user)
// 3. DON'T use useEffect for data that depends on other data
useEffect(() => { fetchUser(userId) }, [userId]);
useEffect(() => { fetchPosts(user?.id) }, [user]); // Waterfall!
// Instead, use dependent queries:
const { data: user } = useQuery({
queryKey: ['users', userId],
queryFn: () => fetchUser(userId),
});
const { data: posts } = useQuery({
queryKey: ['posts', user?.id],
queryFn: () => fetchPosts(user.id),
enabled: !!user?.id, // Only runs when user is loaded
});
// 4. DON'T ignore error states
// Always handle loading, error, AND empty states
// 5. DON'T store server data in Redux/Zustand
// That's what React Query/SWR are for
12. Decision Framework: Which Approach Should You Use?
START
|
v
Is this a Next.js App Router project?
|
YES --> Can this data be fetched on the server?
| |
| YES --> Use Server Components (async/await)
| | + React Query for client-side mutations
| |
| NO --> Use React Query on the client
|
NO
|
v
Is it a simple project with few API calls?
|
YES --> Are you allergic to dependencies?
| |
| YES --> fetch + custom hook (but be careful)
| |
| NO --> SWR (simpler API, smaller bundle)
|
NO
|
v
Does the app have complex server state?
(mutations, optimistic updates, pagination, caching needs)
|
YES --> React Query (TanStack Query) <-- THIS IS THE DEFAULT CHOICE
|
NO --> SWR or React Query (can't go wrong with either)
The TL;DR
| Scenario | Use This |
|---|---|
| New React SPA | React Query |
| Next.js App Router | Server Components + React Query for client mutations |
| Simple app, few endpoints | SWR |
| Need HTTP interceptors | Axios (as the fetcher for React Query) |
| Learning / prototyping |
fetch in useEffect (just know its limits) |
| Enterprise app with complex state | React Query + Axios + Error Boundaries |
| Static site / blog | Server Components or getStaticProps
|
Recommended Stack for Most Projects
React Query (server state) + Axios (HTTP client) + Zustand (client state)
React Query manages everything that comes from the server. Axios handles the actual HTTP requests with interceptors for auth and error handling. Zustand (or your preferred state manager) handles purely client-side state like UI toggles and form state. Clean separation, each tool doing what it's best at.
Wrapping Up
Data fetching in React has come a long way from componentDidMount. The ecosystem has matured to the point where you really don't need to manage loading/error/data states manually anymore. React Query handles the hard parts — caching, deduplication, background refetching, retry logic, race conditions — so you can focus on building features.
If you take one thing from this post: stop putting fetch calls in useEffect for anything beyond a toy project. Use React Query. Your future self will thank you.
If you found this helpful, let's connect! I write about frontend engineering, system design, and developer tooling.
Top comments (0)