flowchart TD
A[Need to fetch data?] --> B{Who needs this data?}
B -- One component --> C{Fetch complexity?}
B -- Many components --> D{Framework / server?}
C -- Simple --> E[useEffect + useState]
C -- Complex / reused --> F[useFetch custom hook]
D -- Next.js App Router --> G[Server Component\nasync/await direct]
D -- Client-only --> H[React Query / SWR]
H --> I[Caching, dedup, background refetch]
G --> J[Zero client JS, streaming]
Good for simple, one-off fetches in a single component.
import { useState, useEffect } from 'react';
function PostList() {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
fetch('https://jsonplaceholder.typicode.com/posts?_limit=5')
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(data => {
if (!cancelled) setPosts(data);
})
.catch(err => {
if (!cancelled) setError(err.message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; }; // cleanup — prevent setState on unmounted component
}, []); // empty deps = fetch once on mount
if (loading) return <p>Loading…</p>;
if (error) return <p>Error: {error}</p>;
return (
<ul>
{posts.map(post => <li key={post.id}>{post.title}</li>)}
</ul>
);
}Re-fetch whenever a prop or state value changes.
import { useState, useEffect } from 'react';
function UserDetail({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
if (!userId) return;
const controller = new AbortController();
setLoading(true);
setUser(null);
setError(null);
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(res => res.json())
.then(setUser)
.catch(err => {
if (err.name !== 'AbortError') setError(err.message);
})
.finally(() => setLoading(false));
return () => controller.abort(); // cancel if userId changes before fetch resolves
}, [userId]); // re-fetch when userId changes
if (!userId) return <p>Select a user</p>;
if (loading) return <p>Loading user {userId}…</p>;
if (error) return <p>Error: {error}</p>;
return <p>{user?.name}</p>;
}Fetch multiple resources at the same time using Promise.all.
import { useState, useEffect } from 'react';
function Dashboard() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
Promise.all([
fetch('/api/stats').then(r => r.json()),
fetch('/api/activity').then(r => r.json()),
fetch('/api/notifications').then(r => r.json()),
])
.then(([stats, activity, notifications]) => {
setData({ stats, activity, notifications });
})
.catch(err => setError(err.message))
.finally(() => setLoading(false));
}, []);
if (loading) return <Skeleton />;
if (error) return <ErrorBanner message={error} />;
return (
<>
<StatsPanel data={data.stats} />
<ActivityFeed items={data.activity} />
<NotificationBell items={data.notifications} />
</>
);
}Cleaner async syntax inside useEffect.
useEffect(() => {
// useEffect callback can't be async directly
// Use an IIFE to enable async/await
(async () => {
try {
const res = await fetch('/api/data');
const json = await res.json();
setData(json);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
})();
}, []);Handles caching, background refetch, deduplication, and error retries.
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
// GET
function TodoList() {
const { data, isLoading, isError } = useQuery({
queryKey: ['todos'],
queryFn: () => fetch('/api/todos').then(r => r.json()),
staleTime: 60_000, // cache fresh for 1 minute
});
if (isLoading) return <Spinner />;
if (isError) return <p>Failed to load</p>;
return <ul>{data.map(t => <li key={t.id}>{t.title}</li>)}</ul>;
}
// POST / mutation
function AddTodo() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (text) => fetch('/api/todos', {
method: 'POST',
body: JSON.stringify({ text }),
headers: { 'Content-Type': 'application/json' },
}).then(r => r.json()),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] }); // refetch list
},
});
return (
<button onClick={() => mutation.mutate('New task')} disabled={mutation.isPending}>
{mutation.isPending ? 'Adding…' : 'Add Todo'}
</button>
);
}Update the UI immediately, then sync with the server. Roll back on failure.
import { useQueryClient, useMutation } from '@tanstack/react-query';
function LikeButton({ postId, initialLikes }) {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: () => fetch(`/api/posts/${postId}/like`, { method: 'POST' }).then(r => r.json()),
onMutate: async () => {
await queryClient.cancelQueries({ queryKey: ['post', postId] });
const prev = queryClient.getQueryData(['post', postId]);
// Optimistically update cache
queryClient.setQueryData(['post', postId], old => ({
...old,
likes: old.likes + 1,
}));
return { prev }; // context for rollback
},
onError: (err, _, context) => {
queryClient.setQueryData(['post', postId], context.prev); // rollback
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['post', postId] }); // sync
},
});
return (
<button onClick={() => mutation.mutate()}>
❤️ {initialLikes}
</button>
);
}sequenceDiagram
participant C as Component
participant H as useFetch / useQuery
participant N as Network
participant S as Server
C->>H: Mount / dep changes
H->>H: Set loading=true
H->>N: fetch(url, { signal })
N->>S: HTTP GET
S-->>N: 200 OK + JSON
N-->>H: Response
H->>H: setData(json), loading=false
H-->>C: Re-render with data
Note over C,H: User navigates away
C->>H: Cleanup (unmount)
H->>N: controller.abort()
N--xS: Request cancelled
| Mistake | Problem | Fix |
|---|---|---|
async directly in useEffect |
Returns a Promise, not a cleanup function | Use IIFE or inner async function |
| No cleanup / no abort | Memory leaks, stale setState on unmounted component |
AbortController + cleanup |
Missing loading state |
Flash of empty UI | Always initialise loading: true |
Fetching in render |
New request on every render | Always inside useEffect |
[] deps with used variables |
Stale closure | Add all used values to deps array |
Catching AbortError |
Shows error on intentional cancellation | Check err.name !== 'AbortError' |
-
Why can't
useEffectbeasync?useEffectmust return eitherundefinedor a cleanup function. Anasyncfunction always returns a Promise, which React silently ignores — breaking cleanup. -
What is the race condition problem in data fetching?
If
userIdchanges quickly, two requests may be in-flight simultaneously. The slower one could resolve last and overwrite the correct data. Fix: cancel the previous request withAbortControlleror acancelledboolean flag. -
What does
staleTimedo in React Query?It defines how long cached data is considered fresh. During that window, React Query serves the cache without making a network request.