Fetching in useEffect: The Basic Approach
The most direct approach fetches data in a useEffect on mount, tracking loading and error state manually. It works, but every component re-implements the same loading/error/caching logic from scratch.
Manual Fetching with useEffect
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => {
if (!cancelled) {
setUser(data);
setLoading(false);
}
})
.catch((err) => {
if (!cancelled) {
setError(err);
setLoading(false);
}
});
return () => { cancelled = true; }; // avoid setting state after unmount
}, [userId]);
if (loading) return <p>Loading...</p>;
if (error) return <p>Something went wrong.</p>;
return <h2>{user.name}</h2>;
}Why the "cancelled" Flag Matters
If userId changes quickly (like a user clicking through several profiles fast), an older, slower request could resolve after a newer one, overwriting fresh data with stale data. Tracking whether the effect has been cleaned up prevents that race condition.
Data-Fetching Libraries
Libraries like TanStack Query (React Query) and SWR handle caching, deduplication, background refetching, and race conditions out of the box, removing the need to hand-write the pattern above in every component.
Fetching with TanStack Query
import { useQuery } from "@tanstack/react-query";
function UserProfile({ userId }) {
const { data: user, isLoading, error } = useQuery({
queryKey: ["user", userId],
queryFn: () => fetch(`/api/users/${userId}`).then((res) => res.json()),
});
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Something went wrong.</p>;
return <h2>{user.name}</h2>;
}What These Libraries Add
| Feature | Benefit |
|---|---|
| Caching by key | Avoids re-fetching the same data across components |
| Automatic deduplication | Multiple components requesting the same data trigger one request |
| Background refetching | Keeps data fresh without a full loading state |
| Built-in race condition handling | No manual "cancelled" flag needed |
Framework-Level Data Fetching
In frameworks with Server Components (like Next.js’s App Router), Server Components can fetch data directly with async/await during rendering — no useEffect, loading state, or client-side request needed at all for that initial data.
Fetching Directly in a Server Component
async function ProductPage({ params }) {
const product = await fetch(`https://api.example.com/products/${params.id}`).then(
(res) => res.json()
);
return <h1>{product.name}</h1>;
}
// No loading state needed — the page isn't sent to the browser until this resolvesBest Practice
For a plain client-side React app, reach for a data-fetching library (TanStack Query or SWR) over hand-rolled useEffect fetching as soon as the app has more than a couple of data-dependent components — the caching and race-condition handling alone are worth it.