renderpx

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.

tsx
Loading...

Here is the exact sequence when the user navigates from /profile/1 to /profile/2:

TimeEventState after
t=0userId changes '1' → '2'. useEffect fires.user: user1 (stale), fetch for user2 in-flight
t=80msuser2 response arrives. setUser(user2).user: user2 ✓ — correct
t=200msuser1 response arrives (slow). setUser(user1).user: user1 ✗ — wrong user, no error

Why it's hard to catch

This only happens when user1's request is slower than user2's — a timing condition that doesn't exist in local development (where the "API" is often instant) but happens regularly in production under load or on slow connections. The result is wrong data in the UI with no error, no warning, and no way for the user to know.

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:

tsx
Loading...

Applying this to the timeline above:

TimeEventResult
t=0userId changes. Cleanup runs: cancelled₁ = true. New effect fires.user1 run is now cancelled
t=80msuser2 response arrives. cancelled₂ = false → setUser(user2).user: user2 ✓
t=200msuser1 response arrives. cancelled₁ = true → setUser skipped.user: user2 ✓ — stale response discarded

What this does and doesn't do

The cancelled flag prevents the stale response from updating state — but the network request for user1 still runs to completion. The bandwidth is wasted, and the connection slot is occupied. For most apps this is fine. For mobile users on metered connections, or for requests that trigger server-side work, you want to cancel the request itself — not just discard the response.

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.

tsx
Loading...
Cancelled flagAbortController
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 supportUniversalAll 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:

tsx
Loading...

The same component with React Query

tsx
Loading...

Note on the queryFn signal

React Query passes an 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:

One-time initialisation

Fetching configuration on app startup, where caching and refetch are irrelevant. The request runs once and the result never goes stale.

Non-HTTP async work

Reading from IndexedDB, Web Workers, or WebRTC. These aren't HTTP requests — AbortController doesn't apply and React Query's model doesn't fit.

Mutations with no return value

Logging, analytics, fire-and-forget side effects. These don't need caching or retry coordination.

The rule of thumb

If you need the data in a React component and it comes from a server, use React Query. If the async work is a side effect that isn't directly tied to rendering — or if you're in a context where React Query doesn't make sense — use useEffect with AbortController (for fetch) or a cancelled flag (for everything else).

Related