Fetching Data with useEffect: Race Conditions
Race conditions are the silent killer of dynamic data-fetching components. When props or state change rapidly, multiple fetch requests may be in flight simultaneously. Without proper handling, slower requests can overwrite faster ones, causing your UI to display stale data. This guide teaches you to detect race conditions, prevent them with AbortController, and build bulletproof, production-grade data-fetching patterns.
What Is a Race Condition in React Data Fetching?
A race condition occurs when multiple asynchronous operations are in progress and their completion order is unpredictable. In React, this happens with useEffect when a dependency (like a userId prop) changes while a fetch request is still pending.
Consider this scenario: a UserProfile component fetches a user by ID. The userId prop changes from 1 to 2 almost instantly:
- Effect runs with
userId = 1→ startsfetch('/users/1') userIdprop changes to2→ effect runs again → startsfetch('/users/2')fetch('/users/1')is slow and finishes afterfetch('/users/2')- Both complete: the state is set to user 1 first, then overwritten by user 1 again
- UI displays user 1, but the current prop is
userId = 2— stale data
The issue stems from setting state with data from an outdated fetch. The component doesn't know which response belongs to the current props.
How Do You Prevent Race Conditions?
The solution is to cancel outdated requests before they complete and update state. The AbortController browser API lets you abort fetch requests. Use it in the useEffect cleanup function to abort the previous request whenever the dependency changes.
Here's the pattern: When your effect re-runs due to a dependency change, React runs the cleanup function from the previous effect. In cleanup, call controller.abort() to cancel the pending fetch. The aborted fetch throws an AbortError, which you catch and ignore, so no state is updated.
Building a Race-Condition-Proof Fetching Component
Here's the complete pattern combining loading state, error handling, and race-condition prevention:
import React, { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// Create an AbortController for this fetch
const controller = new AbortController();
const signal = controller.signal;
const fetchUser = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(
`https://jsonplaceholder.typicode.com/users/${userId}`,
{ signal }
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
setUser(data);
} catch (e) {
// Ignore AbortError; it means a newer request started
if (e.name !== 'AbortError') {
setError(e);
}
} finally {
// Only set loading to false if this request wasn't aborted
if (!signal.aborted) {
setLoading(false);
}
}
};
fetchUser();
// Cleanup function: abort the fetch if the effect re-runs
return () => {
controller.abort();
};
}, [userId]); // Re-run when userId changes
if (loading) return <p>Loading profile...</p>;
if (error) return <p>Error: {error.message}</p>;
return (
<div>
<h1>{user?.name}</h1>
<p>Email: {user?.email}</p>
</div>
);
}
export default UserProfile;
Step-by-step execution:
- Component renders with
userId = 1. Effect runs, createsAbortController, starts fetch. userIdprop changes to2. React schedules the effect to re-run.- Before the new effect runs, React calls the cleanup function from the previous effect, calling
controller.abort(). - The
fetch('/users/1')request is aborted mid-flight. It throwsAbortErrorin the catch block. Because you checkif (e.name !== 'AbortError'), you ignore it—no state update. - The new effect runs, creates a new controller, starts
fetch('/users/2'). - Only the most recent fetch completes and updates state. No stale data.
Understanding the Cleanup Function's Role
The useEffect cleanup function runs three times:
- When the component unmounts (cancel pending requests)
- Before the effect re-runs due to a dependency change (cancel the old request before the new one starts)
- When the component is removed from the DOM
This is why cleanup is perfect for aborting stale requests. Each time a dependency changes, cleanup cancels the previous fetch, ensuring only the latest request can update state.
Error Handling with AbortController
When controller.abort() is called, the promise chain throws AbortError. You must distinguish between a user-cancelled request (safe to ignore) and real errors (display to the user):
catch (e) {
if (e.name === 'AbortError') {
// Request was intentionally cancelled, ignore
} else {
// Real error: network failure, parsing error, etc.
setError(e);
}
}
Also, in the finally block, only set setLoading(false) if the request wasn't aborted. Otherwise, you might set loading to false right when a new request starts, creating a flickering loading state.
Best Practices for Dynamic Data Fetching
Always Use AbortController for Dependent Fetches: If a fetch depends on a prop or state, use the pattern above. It's the only reliable way to prevent race conditions.
Reset Loading and Error on New Requests: When a dependency changes and a new fetch starts, reset loading to true and error to null. This provides visual feedback that fresh data is being loaded.
Use Optional Chaining in JSX: With data that may be undefined, use user?.name instead of user.name to avoid crashes while loading.
Separate Concerns: Keep data fetching in useEffect, not in event handlers or component bodies. This makes dependencies explicit and prevents accidental re-fetches.
Anti-Patterns to Avoid
Never Set State in the Catch Block Without Checking Abort Status: If you set state for an aborted request, it may overwrite the new request's state.
// ANTI-PATTERN
catch (e) {
setError(e); // Wrong if e is AbortError
}
// CORRECT
catch (e) {
if (e.name !== 'AbortError') {
setError(e);
}
}
Do Not Forget the Dependency Array: Omitting it causes the effect to run on every render, making infinite fetches. Forgetting a dependency causes stale data.
Do Not Use Race-Condition Patterns Without Cleanup: If you don't abort the previous request, rapid dependency changes will cause state thrashing and stale data.
Key Takeaways
- Race conditions occur when multiple fetch requests are pending and resolve out of order. The UI displays stale data from an outdated response.
AbortControllerallows you to cancel fetch requests. Abort previous requests in theuseEffectcleanup function before starting new ones.- The cleanup function is called before the effect re-runs (due to dependency changes) and when the component unmounts, making it the perfect place to cancel stale fetches.
- Always check
e.name !== 'AbortError'in catch blocks to distinguish intentionally cancelled requests from real errors. - Reset loading and error state when dependencies change, providing visual feedback that new data is being fetched.
Frequently Asked Questions
What happens if I don't use AbortController?
If a dependency changes rapidly, multiple fetch requests run in parallel. Whichever completes last updates state, potentially with stale data. The UI flickers and displays incorrect information.
Can I use AbortController with async/await?
Yes. Pass the signal to the fetch call: fetch(url, { signal }). If controller.abort() is called, the fetch promise rejects with AbortError.
How do I handle timeouts in addition to race conditions?
Create a timeout abort separately: setTimeout(() => controller.abort(), 5000) in the effect. The AbortError will be caught and handled the same way.
What if the cleanup function is called but I still want to update state?
You can't. Once cleanup runs (due to a dependency change), the previous effect is discarded. If you want to keep the previous state while fetching new data, don't abort—instead, track which request the state came from and ignore older responses based on a timestamp or sequence number.
Does AbortController work in all browsers?
Yes, AbortController is widely supported (2026 coverage is 99%+). For legacy browser support, use a flag in the cleanup function: let isMounted = true; return () => { isMounted = false; }. Only update state if isMounted is true.