Skip to content
RungsySign in

Client Data Fetching

Loading, error, empty, stale, race — the five states everyone forgets four of.

45 minDifficulty 3/5react · apiAI-writtenWritten by a model on 16 August 2026 and not yet read by a person. Checked automatically: schema, the pedagogical rules the hand-written material is held to, and every diagram parsed for real.

Before this

Why this exists

A search box fires a request on every keystroke, and the response for "reac" arrives AFTER the response for "react" because the network is unpredictable — the UI briefly shows stale results for a query the user already moved past. This exact race condition is why client data fetching is harder than "call the API and show the result"; there are five real states, and most bugs come from handling only one of them.

The mental model

Every fetch has five possible states over its lifetime: loading (waiting for a response), success with data, error, empty (loaded successfully but there's nothing to show), and stale (showing old data while a refetch is in flight). A UI that only handles "loading" and "here's the data" will visibly break the moment any of the other three actually happens — which, in production, is often.

How it works

Race conditions between requests are the default, not an edge case

Firing a new request before the previous one resolves — from rapid typing, rapid clicking, or a fast page navigation — means two requests are in flight, and network timing decides which response arrives last. Without explicit protection, the LAST response to arrive wins and gets rendered, even if it was for an earlier, now-irrelevant request.

Guard against stale responses with a cancellation flag or AbortController

The cleanest fix isn't preventing the race — it's making sure only the response matching the CURRENT request actually updates state. A cleanup flag set when a new request supersedes an old one, checked before calling the state setter, or an `AbortController` that cancels the outdated request outright, both solve this; ignoring it does not.

Empty and error are genuinely different states requiring different UI

A search returning zero results is a SUCCESSFUL fetch with an empty array — showing 'Something went wrong' would be actively misleading. A network failure is a genuine error, distinct from 'zero results found,' and conflating them (by, say, treating any falsy-looking response as an error) produces confusing UI for both cases.

Showing stale data during a refetch is often better than a loading spinner

Refetching a list every 30 seconds and blanking it to a spinner each time is visually jarring for data that hasn't necessarily changed. Keeping the previous data visible (perhaps dimmed slightly) while a background refetch happens, then swapping to the new data once it arrives, produces a much smoother experience for the common case where most of the data is unchanged.

The mechanism

A fetch begins by setting a loading flag and clearing any previous error. The request resolves either successfully (setting data, clearing loading) or with an error (setting an error state, clearing loading). If a newer request was started before this one resolved, a guard — a cancellation flag or an aborted signal — prevents this now-outdated response from overwriting state with stale data.

What people get wrong

Handling loading and success covers the vast majority of real cases.
Error and empty states are just as common in real usage as success — a network blip, a validation failure, or a genuinely empty result set all happen routinely, and a UI unprepared for them either crashes, hangs, or shows confusingly wrong content. Prototypes often only test the happy path locally with reliable network and non-empty data, which is exactly why these other states get missed until production traffic exposes them.
A race condition between two fetches is a rare edge case not worth guarding against.
Any UI where a fetch can be re-triggered before the previous one resolves — search-as-you-type, rapid pagination clicks, quick navigation — encounters this routinely under normal, fast usage, not just as a rare fluke. Underestimating how often this happens leads to skipping the guard entirely, producing an intermittent bug that's hard to reproduce on a fast local network but shows up constantly for real users on variable connections.
An empty result and an error should both just show a generic 'no data' message.
Users need to know the difference between 'your search legitimately found nothing' and 'something went wrong, try again' — conflating the two either hides an actionable error or makes a normal empty state feel alarmingly broken. Distinguishing these states properly is part of respecting what the user actually needs to know to decide their next action.

When not to use it

The data is fetched once on mount and never refetched or re-triggered during the component's lifetime.
A simpler loading/error/success state machine is sufficient — the race condition concern specifically applies when a fetch can be re-triggered before a previous one resolves.
You're fetching the same resource across many components and want automatic caching, deduplication, and refetch behaviour handled for you.
A dedicated data-fetching library, which implements these five states (and race protection) correctly out of the box, rather than hand-rolling the same logic repeatedly.

Terms

Race condition
A bug where the outcome depends on the unpredictable relative timing of two or more operations — here, two in-flight requests resolving in an order different from when they were sent.
AbortController
A browser API for cancelling an in-flight fetch request, used to explicitly discard a request that's been superseded by a newer one.
Stale-while-revalidate
A pattern of showing existing (possibly outdated) data immediately while a fresh fetch happens in the background, swapping in the new data once it arrives.
Empty state
The UI shown when a fetch succeeds but returns no data, distinct from both a loading state and an error state.

In an interview

A search-as-you-type feature occasionally flashes results for an earlier, already-abandoned query. What's happening, and how would you fix it?

  • multiple requests are in flight simultaneously (one per keystroke), and they can resolve out of order
  • the last response to ARRIVE overwrites state, even if it was for an earlier query
  • fix: track which request is current and ignore responses for superseded ones (a ref/flag or AbortController), or debounce the requests themselves

Can you recall it?

What are the five states a client-side fetch can be in, and why does a race condition between requests specifically threaten the 'success' state?

Keep track of this

Add React in Practice to your map and Rungsy will schedule reviews so you actually remember it.