Patterns for async data loading in React
The Short Answer
React offers several patterns for loading async data — from the basic useEffect + useState approach to custom hooks, libraries like React Query/SWR, and the newer Suspense model. Each pattern trades off simplicity against features like caching, deduplication, error handling, and loading states. The right choice depends on your app's complexity and how much infrastructure you want to manage yourself.
Pattern 1: useEffect + useState
The most basic pattern: trigger a fetch in useEffect, store the result in state, and track loading/error states manually. This works for simple cases but gets repetitive quickly — you end up writing the same loading/error/data boilerplate in every component that fetches data.
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((data) => setUser(data))
.catch((err) => {
if (err.name !== 'AbortError') setError(err);
})
.finally(() => setLoading(false));
return () => controller.abort();
}, [userId]);
if (loading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
if (!user) return null;
return <div>{user.name}</div>;
}
Limitations of this pattern
- ❌Repetitive boilerplate (loading, error, data) in every component
- ❌No caching — refetches on every mount even if data hasn't changed
- ❌No deduplication — multiple components fetching the same data make separate requests
- ❌Race conditions require manual AbortController handling
- ❌No background refetching or stale-while-revalidate
Pattern 2: Custom Hook
Extract the fetch logic into a reusable hook that handles loading, error, and abort states. This eliminates boilerplate in components and centralizes the fetching pattern. The hook encapsulates the complexity while exposing a clean interface.
type UseFetchResult<T> = {
data: T | null;
loading: boolean;
error: Error | null;
refetch: () => void;
};
function useFetch<T>(url: string): UseFetchResult<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [fetchCount, setFetchCount] = useState(0);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(url, { signal: controller.signal })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(setData)
.catch((err) => {
if (err.name !== 'AbortError') setError(err);
})
.finally(() => setLoading(false));
return () => controller.abort();
}, [url, fetchCount]);
const refetch = () => setFetchCount((count) => count + 1);
return { data, loading, error, refetch };
}
Now components become much cleaner — they just call the hook and destructure the result. But this still doesn't solve caching or deduplication. Every component instance that calls useFetch('/api/users/1') makes its own request.
function UserProfile({ userId }: { userId: string }) {
const { data: user, loading, error } = useFetch<User>(`/api/users/${userId}`);
if (loading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
if (!user) return null;
return <div>{user.name}</div>;
}
Pattern 3: Data Fetching Libraries (React Query / SWR)
Libraries like TanStack Query (React Query) and SWR solve all the problems of manual fetching: caching, deduplication, background refetching, optimistic updates, pagination, and more. They manage a global cache keyed by query keys, so multiple components requesting the same data share a single request and cache entry.
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then((res) => res.json()),
staleTime: 5 * 60 * 1000, // Consider fresh for 5 minutes
});
if (isLoading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
return <div>{user.name}</div>;
}
// Mutation with cache invalidation
function UpdateUserButton({ userId }: { userId: string }) {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (newName: string) =>
fetch(`/api/users/${userId}`, {
method: 'PATCH',
body: JSON.stringify({ name: newName }),
}),
onSuccess: () => {
// Invalidate and refetch the user query
queryClient.invalidateQueries({ queryKey: ['user', userId] });
},
});
return <button onClick={() => mutation.mutate('New Name')}>Update</button>;
}
The library handles caching, deduplication, refetching on window focus, retry logic, and garbage collection automatically. If two components use the same query key, only one request is made and both components share the cached result.
Pattern 4: Server Components (Next.js)
With React Server Components (in frameworks like Next.js), you can fetch data directly in the component without hooks, effects, or loading states. The component runs on the server, fetches data, and sends the rendered HTML to the client. This eliminates client-side waterfalls and simplifies the data loading model significantly.
// This runs on the server — no useState, no useEffect, no loading state
async function UserProfile({ userId }: { userId: string }) {
const res = await fetch(`https://api.example.com/users/${userId}`, {
next: { revalidate: 300 }, // Cache for 5 minutes
});
if (!res.ok) throw new Error('Failed to fetch user');
const user = await res.json();
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
// Pair with a loading.tsx for Suspense boundary
// app/users/[id]/loading.tsx
export default function Loading() {
return <Spinner />;
}
Server Components eliminate the loading/error/data state machine entirely for initial page loads. The tradeoff is that they can't use hooks or browser APIs — they're purely for rendering with server-side data. Interactive parts still need client components.
Comparison
| Pattern | Caching | Deduplication | Complexity | Best for |
|---|---|---|---|---|
| useEffect + useState | None | None | Low | Simple one-off fetches, learning |
| Custom hook | None (unless you add it) | None | Medium | Reducing boilerplate, small apps |
| React Query / SWR | Built-in | Built-in | Medium | Most production apps |
| Server Components | Framework-level | Framework-level | Low (in supported frameworks) | Next.js / RSC-enabled apps |
Common Pitfalls
Race conditions without cancellation
If a user triggers multiple fetches quickly (typing in search, navigating fast), responses can arrive out of order. The last response to arrive wins — which might not be the latest request.
✅Use AbortController to cancel previous requests, or use a library that handles this automatically (React Query, SWR).
Waterfall requests
Fetching data in nested components creates sequential waterfalls — child can't fetch until parent renders. Each level adds latency.
✅Hoist data fetching to the route level, use parallel fetching (Promise.all), or use Server Components which can fetch in parallel on the server.
No caching strategy
Refetching the same data on every mount wastes bandwidth and makes the app feel slow. Users see loading spinners for data they already loaded seconds ago.
✅Use a caching library (React Query, SWR) or implement stale-while-revalidate: show cached data immediately, refetch in the background.
Why Interviewers Ask This
This question reveals how much production React experience you have. Interviewers want to see that you know the basic useEffect pattern and its limitations, understand why libraries like React Query exist (caching, deduplication, race conditions), can discuss the tradeoffs between approaches, and are aware of modern patterns like Server Components. A junior might only know useEffect; a senior can discuss when each pattern is appropriate and why.
Quick Revision Cheat Sheet
Basic: useEffect + useState + AbortController — works but repetitive
Custom hook: Extracts boilerplate, still no caching or deduplication
React Query/SWR: Caching, deduplication, background refetch, retry — production standard
Server Components: Fetch on server, no client loading state, eliminates waterfalls
Race conditions: Always cancel stale requests (AbortController or library)
Waterfalls: Fetch at route level or use Promise.all for parallel requests