How to Fetch Data from an API in React
Advertisement
🧩 Scenario
Architecture Walkthrough
The useEffect Fetch Pattern
Data fetching in React is a side effect: it happens outside the render cycle, produces a result asynchronously, and updates state when complete. useEffect is the hook for side effects. The fetch pattern inside useEffect is: define an async function, call it immediately, update the three state variables (data, loading, error) based on the outcome, and reset loading to false in a finally block regardless of success or failure.
The dependency array controls when the effect re-runs. An empty array [] means the effect runs once after the initial render (equivalent to componentDidMount). Including a prop like userId means the effect re-runs every time userId changes, enabling re-fetching when the data source changes.
The Async Effect Pattern
useEffect callbacks must be synchronous. An async function implicitly returns a Promise, and if the callback returns a Promise, useEffect ignores it and cannot use the cleanup return value. The correct pattern is to define the async function inside the effect and call it: async function fetchData() {...}; fetchData().
Cleanup and Race Conditions
When the component unmounts or a dependency changes before the current fetch completes, the in-flight request should be cancelled (or its result discarded) to prevent setting state on an unmounted component. The cleanup function (returned from useEffect) is the correct place for this. Use an AbortController to cancel the fetch itself, or use a cancelled boolean flag to ignore the result.
Key Code Explained
// Full pattern: loading, error, data, cleanup, and re-fetch on dependency
interface User {
id: number;
name: string;
email: string;
}
function UserProfile({ userId }: { userId: number }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// Reset state when userId changes — prevents stale data flash
setUser(null);
setIsLoading(true);
setError(null);
const controller = new AbortController(); // for cleanup
async function fetchUser() {
try {
const res = await fetch(
`https://jsonplaceholder.typicode.com/users/${userId}`,
{ signal: controller.signal }, // passes AbortSignal to fetch
);
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
const data: User = await res.json();
setUser(data);
} catch (err) {
// AbortError is thrown when controller.abort() is called — ignore it
if (err instanceof Error && err.name !== 'AbortError') {
setError(err.message);
}
} finally {
setIsLoading(false);
}
}
fetchUser();
// Cleanup: cancel the in-flight request when userId changes or component unmounts
return () => controller.abort();
}, [userId]); // re-runs whenever userId changes
if (isLoading) return <UserSkeleton />;
if (error) return <ErrorMessage message={error} />;
if (!user) return null;
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
// Extracting to a custom hook for reuse
function useUser(userId: number) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setUser(null);
setIsLoading(true);
setError(null);
const controller = new AbortController();
async function fetchUser() {
try {
const res = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data: User = await res.json();
setUser(data);
} catch (err) {
if (err instanceof Error && err.name !== 'AbortError') {
setError(err.message);
}
} finally {
setIsLoading(false);
}
}
fetchUser();
return () => controller.abort();
}, [userId]);
return { user, isLoading, error };
}
// Clean component — all logic in the hook
function UserCard({ userId }: { userId: number }) {
const { user, isLoading, error } = useUser(userId);
if (isLoading) return <Skeleton />;
if (error) return <ErrorMessage message={error} />;
if (!user) return null;
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
// How React Query replaces all of this
import { useQuery } from '@tanstack/react-query';
function UserCardWithQuery({ userId }: { userId: number }) {
const { data: user, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () =>
fetch(`/api/users/${userId}`).then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<User>;
}),
});
if (isLoading) return <Skeleton />;
if (error) return <ErrorMessage message={(error as Error).message} />;
if (!user) return null;
return <div><h2>{user.name}</h2></div>;
}
// React Query automatically handles: caching, deduplication, background refetch,
// stale time, retry on error, loading/error states — all with less code
Extracting the fetch logic to a custom hook (useUser) is the correct production pattern. The component becomes display-only. The hook owns state and effects. This makes both the hook and the component independently testable and reusable.
Tradeoffs
| Approach | Caching | Deduplication | Background refetch | Error retry | Code size |
|---|---|---|---|---|---|
| Manual useEffect | No | No | No | Manual | More |
| React Query / SWR | Yes | Yes | Yes | Built-in | Less |
What Interviewers Actually Check
- Whether you define an async function inside
useEffectinstead of making the callback async - Whether you handle loading, error, and success states
- Whether you reset loading in
finally(not just intry) - Whether you add cleanup with
AbortControlleror a cancelled flag - Whether you know React Query / SWR and when they are a better choice
Follow-Up Questions
- How would you implement pagination where clicking "Next" fetches the next page of results?
- How does
Suspensefor data fetching (React 18) differ from the manual loading state pattern? - How does React Query's
staleTimeandgcTimecontrol when cached data is refetched? - How would you implement optimistic updates (show the result before the API confirms) with React Query?
- In Next.js App Router, how does fetching in a Server Component differ from fetching in a
useEffectin a Client Component?
Common Candidate Mistakes
- Making the
useEffectcallback itselfasync, which makes it return a Promise that React ignores and cannot use for cleanup - Not handling the error state, showing a blank UI when the fetch fails
- Setting
setIsLoading(false)only in thetryblock, leaving the spinner running forever when the fetch throws - Not returning a cleanup function, causing
setStatecalls on unmounted components when the fetch completes after navigation - Not including
userIdin the dependency array, causing the component to display stale data after the prop changes
Interview Readiness Checklist
Before you leave this question, make sure you can answer:
- Can you write a
useEffectthat fetches data with async/await and manages loading, error, and data state? - Can you explain why the
useEffectcallback cannot be async directly? - Can you add an
AbortControllercleanup to cancel the request on unmount or dependency change? - Can you re-fetch when a prop changes by including it in the dependency array?
- Can you articulate what React Query or SWR adds over manual
useEffectfetching?
Summary
Data fetching in React uses useEffect with an inner async function. The pattern is: define an async function inside the effect body, call it immediately, and manage three state variables: isLoading (starts true, set to false in finally), error (set in catch), and data (set in try). The finally block ensures loading is reset even when the fetch fails.
The dependency array controls re-fetching. An empty array means fetch once on mount. Including a prop like userId means re-fetch whenever that prop changes. When dependencies change before the current fetch completes, the cleanup function must cancel the in-flight request. Use AbortController with { signal: controller.signal } passed to fetch, and call controller.abort() in the cleanup return.
Extract the fetch logic to a custom hook to keep the component display-only and the fetch logic reusable and independently testable. For production applications, React Query or SWR replace manual useEffect fetching with built-in caching, deduplication, background refetching, and retry logic, significantly reducing boilerplate and eliminating entire categories of bugs.
Can I use async directly in the useEffect callback?
No. The useEffect callback must be synchronous (or return a cleanup function). Define an async function inside the effect and call it immediately. Marking the effect itself as async would make it return a Promise, which useEffect does not handle.
Advertisement
Stay Updated
Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.
Advertisement