useEffect and Async Cleanup
The race condition every useEffect fetch has, two ways to fix it, and why React Query exists
The Race Condition
Every useEffect fetch has a latent race condition. It only surfaces when two requests are in-flight simultaneously — which happens whenever a prop that the effect depends on changes quickly.
Here is the exact sequence when the user navigates from /profile/1 to /profile/2:
| Time | Event | State after |
|---|---|---|
| t=0 | userId changes '1' → '2'. useEffect fires. | user: user1 (stale), fetch for user2 in-flight |
| t=80ms | user2 response arrives. setUser(user2). | user: user2 ✓ — correct |
| t=200ms | user1 response arrives (slow). setUser(user1). | user: user1 ✗ — wrong user, no error |
Why it's hard to catch
Fix 1: The Cancelled Flag
The useEffect cleanup function runs before the next effect fires. You can use it to set a flag that tells the async callback to discard its result:
Applying this to the timeline above:
| Time | Event | Result |
|---|---|---|
| t=0 | userId changes. Cleanup runs: cancelled₁ = true. New effect fires. | user1 run is now cancelled |
| t=80ms | user2 response arrives. cancelled₂ = false → setUser(user2). | user: user2 ✓ |
| t=200ms | user1 response arrives. cancelled₁ = true → setUser skipped. | user: user2 ✓ — stale response discarded |
What this does and doesn't do
Fix 2: AbortController
AbortController is a Web API that cancels the network request itself. The browser closes the connection when abort() is called — no response is received, no bandwidth is used past that point.
| Cancelled flag | AbortController | |
|---|---|---|
| Prevents stale state update | ✓ | ✓ |
| Cancels the network request | ✗ — request completes | ✓ — connection closed |
| Saves bandwidth | ✗ | ✓ |
| Stops server-side work | ✗ | ✓ (if server respects abort signal) |
| Works with non-fetch async (setTimeout, WebSocket, etc.) | ✓ | ✗ — fetch-specific |
| Browser support | Universal | All modern browsers |
Default to AbortController for fetch-based data loading. Use the cancelled flag for non-fetch async work (timers, WebSocket messages, IndexedDB reads) where AbortController doesn't apply.
What You Still Don't Have
With AbortController in place, the race condition is fixed. But data fetching in production needs more than correct sequencing:
The same component with React Query
Note on the queryFn signal
AbortController signal into queryFn automatically. When a query is cancelled (because the component unmounts, or because a newer query supersedes it), the signal aborts — and the network request is cancelled. You get the correct behavior without writing the cleanup code yourself.When useEffect + fetch Is Still the Right Tool
React Query is the right default for server data. But there are cases where a raw useEffect fetch (with cleanup) is appropriate:
Fetching configuration on app startup, where caching and refetch are irrelevant. The request runs once and the result never goes stale.
Reading from IndexedDB, Web Workers, or WebRTC. These aren't HTTP requests — AbortController doesn't apply and React Query's model doesn't fit.
Logging, analytics, fire-and-forget side effects. These don't need caching or retry coordination.
The rule of thumb
useEffect with AbortController (for fetch) or a cancelled flag (for everything else).