Race Conditions in UI State: How They Happen and How to Prevent Them
A user clicks a tab. The data loads. They click a different tab before the first responds. The second request resolves first. Then the first resolves — and overwrites the UI with stale data from the tab they already left. The interface now shows data for Tab 1 while the header says Tab 2.
This is a UI race condition, and it lives in practically every application that fires async requests based on user interaction without handling cancellation or ordering.
Why Race Conditions Happen
JavaScript is single-threaded but async. When multiple requests are in-flight simultaneously, their responses can arrive in any order — regardless of when they were sent. If your state update is "whoever responds last wins," that's a race condition.
// ❌ Classic race condition
function UserProfile({ userId }) {
const [user, setUser] = useState(null)
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data)) // ← no cancellation, no ordering check
}, [userId])
return <div>{user?.name}</div>
}
If userId changes from 1 to 2:
- Request for user
1fires userIdchanges to2, effect re-runs- Request for user
2fires - Request
2resolves first — renders user2✓ - Request
1resolves — renders user1✗
The UI now shows the wrong user.
Fix 1: Cancel with AbortController
The cleanest fix: cancel the previous request when the effect re-runs:
useEffect(() => {
const controller = new AbortController()
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(res => res.json())
.then(data => setUser(data))
.catch(err => {
if (err.name !== 'AbortError') setError(err)
})
return () => controller.abort()
}, [userId])
When userId changes, React's cleanup runs, the old request is aborted, and only the new request can update state.
Fix 2: Ignore Stale Results
If you can't cancel (e.g. using a third-party library that doesn't support signals), track whether the effect is still "current":
useEffect(() => {
let current = true
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
if (current) setUser(data) // only update if still relevant
})
return () => { current = false }
}, [userId])
When the effect cleans up, current is set to false. The old request may still resolve, but it checks current before calling setUser — and silently discards the stale result.
Fix 3: Request Sequencing
For cases where you genuinely need to process responses in order (e.g. a live feed), track the request number:
const requestId = useRef(0)
useEffect(() => {
const id = ++requestId.current
fetch(`/api/data?q=${query}`)
.then(res => res.json())
.then(data => {
if (id === requestId.current) setData(data)
})
}, [query])
Only the response matching the latest request ID is applied. Earlier responses are discarded even if they arrive after later ones.
Optimistic Updates and Rollbacks
Race conditions get more complex with optimistic UI — where you apply the expected result before the server responds:
function toggleLike(postId) {
// Optimistically update
setPosts(posts.map(p =>
p.id === postId ? { ...p, liked: !p.liked } : p
))
// Send to server
api.toggleLike(postId)
.catch(() => {
// Rollback on failure
setPosts(posts.map(p =>
p.id === postId ? { ...p, liked: !p.liked } : p
))
})
}
A race condition here: the user clicks like, then unlike, before either resolves. The two requests can arrive at the server in the wrong order, leaving the server state mismatched from the UI. Solutions include debouncing, request cancellation, or using a server-assigned version number to verify the final state.
In React Query / TanStack Query
Data fetching libraries manage this largely automatically. React Query cancels in-flight requests when a query key changes, deduplicates concurrent requests for the same key, and only applies the latest result. If you're writing manual useEffect fetching, switching to a query library is often the most practical fix for race conditions.
State Machines for Complex Flows
When an async operation has multiple states that can race (idle → loading → success/error, with the possibility of new requests while loading), explicit state machines prevent impossible state combinations:
type State =
| { status: 'idle' }
| { status: 'loading', requestId: number }
| { status: 'success', data: User }
| { status: 'error', error: Error }
Encoding requestId in the loading state lets you check whether a response belongs to the current request without a closure variable.
Conclusion
UI race conditions are caused by multiple async operations sharing a single piece of state where the last-writer-wins policy produces incorrect results. The fix is always some form of coordination: cancel old requests with AbortController, ignore stale results with a flag or ID, or use a data fetching library that handles ordering internally. Race conditions are invisible when development is fast — they surface in production where network latency is real and user interaction sequences are unpredictable.
