Data Fetching with Effects
Fetching data in an effect is common in client-rendered React apps, but it comes with edge cases: loading state, errors, race conditions, cancellation, and repeated requests.
Framework loaders and data libraries often handle these better. Still, understanding effect-based fetching is important.
Basic Fetching Pattern
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [error, setError] = useState(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
async function loadUser() {
setIsLoading(true);
setError(null);
try {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
setUser(data);
} catch (error) {
setError(error);
} finally {
setIsLoading(false);
}
}
loadUser();
}, [userId]);
}This works as a starting point, but it has a race condition.
Race Conditions
If userId changes from 1 to 2, request 1 may finish after request 2. The older response could overwrite newer data.
One simple fix is an ignore flag.
useEffect(() => {
let ignore = false;
async function loadUser() {
setIsLoading(true);
setError(null);
try {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
if (!ignore) {
setUser(data);
}
} catch (error) {
if (!ignore) {
setError(error);
}
} finally {
if (!ignore) {
setIsLoading(false);
}
}
}
loadUser();
return () => {
ignore = true;
};
}, [userId]);Aborting Requests
AbortController can cancel fetch work that is no longer needed.
useEffect(() => {
const controller = new AbortController();
async function loadUser() {
try {
const response = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
});
const data = await response.json();
setUser(data);
} catch (error) {
if (error.name !== "AbortError") {
setError(error);
}
}
}
loadUser();
return () => controller.abort();
}, [userId]);Aborting is useful, but still handle stale results defensively when APIs or wrappers do not support cancellation.
Loading and Empty States
Plan for:
- initial loading
- refetch loading
- empty results
- network errors
- server validation errors
- unauthorized responses
Users should know whether the app is waiting, failed, or truly has no data.
When Not to Fetch in an Effect
Avoid effect fetching when:
- the framework has route loaders or server components
- data should be prefetched before rendering
- caching and deduplication matter
- many components need the same server state
In those cases, use the framework's data APIs or a library such as TanStack Query.
What race condition can happen when fetching in an effect?
Common Mistakes
Do not make the effect callback itself async.
useEffect(async () => {
// wrong: returns a promise instead of cleanup
}, []);Define and call an async function inside the effect.
Do not ignore HTTP error status. fetch only rejects for network errors by default.
if (!response.ok) {
throw new Error("Request failed");
}Practice Challenge
Build a UserDetails component that fetches by userId.
Requirements:
- show loading, error, empty, and success states
- abort or ignore stale requests
- include
userIdin dependencies - handle non-OK HTTP responses
- explain when you would replace this with a data-fetching library
Recap
Effect-based fetching is useful but easy to get subtly wrong. Handle loading and errors, include dependencies, guard against stale responses, abort when possible, and use a data library when caching or coordination becomes important.