Read alongside
React documentation: Managing State and Effects: Choosing the State Structure
Rules against redundant, duplicated and contradictory state; then the Fetching data sections on ignoring stale responses.
Objectives
- Identify the single source of truth for each piece of UI data and replace stored derived state with computed values.
- Distinguish server state from client state and explain caching, staleness and invalidation for server state.
- Reproduce a stale-response race and fix it with AbortController, a sequence token or both, and explain what debouncing does and does not solve.
- Implement an optimistic update with rollback on failure and reconciliation with the server response on success.
- Handle the failure modes of fetch: network errors, non-2xx responses, timeouts, aborts and malformed bodies.
- Compare offset and cursor pagination and decide when a retry is safe, including idempotency keys for non-idempotent operations.
Why it matters
A UI is a function of state, but that state is split across component memory, the URL, browser storage and a server that other users also modify, connected by requests that can be slow, fail or finish in any order. Interviewers probe this with scenarios: a search box showing results for an old query, a like button that lies after a failed request, a list that duplicates rows while paging. Good answers name the invariant that broke and the mechanism that restores it.
Core ideas
One source of truth, derive the rest. Store each fact once. If a full name follows from first and last name, or a filtered list from the list and the filter, compute it (memoizing if expensive) instead of storing a copy that must be kept in sync. Duplicated state is how interfaces end up disagreeing with themselves.
Server state is a cache. Data fetched from an API is owned by the server: it can change without your knowledge, is shared across screens and may be stale on arrival. Treat it as a cache keyed by everything that determines the response (resource, filters, page, user), with explicit freshness rules (refetch on focus or interval, stale-while-revalidate) and invalidation rules (refetch or patch the affected keys after a mutation). Client state such as open panels, unsaved input or selection is owned by the UI and needs none of that. State that should survive a reload or be shareable, such as filters and the page number, belongs in the URL.
Request races. If a user types "re" and then "rea", two requests are in flight and the first may answer last; applying responses in arrival order shows results for the wrong query. The fixes are often combined:
- Abort the previous request with an
AbortControllerwhen a new one starts;fetchthen rejects with anAbortError, which you ignore rather than display. - Tag each request with an increasing sequence number and apply a response only if its number is still the latest. In React effects, an
ignoreflag set by the cleanup function does the same. - Debounce input to send fewer requests. This reduces load but does not fix ordering, because requests can still overlap.
let latest = 0;
let controller: AbortController | undefined;
async function search(q: string) {
const id = ++latest;
controller?.abort();
controller = new AbortController();
try {
const res = await fetch('/api/search?q=' + encodeURIComponent(q), { signal: controller.signal });
if (!res.ok) throw new Error('HTTP ' + res.status);
const data: unknown = await res.json();
if (id === latest) render(data);
} catch (e) {
if ((e as Error).name !== 'AbortError' && id === latest) showError(e);
}
}
Optimistic updates. Apply the expected result locally, send the request, and on failure restore a snapshot taken before the change and tell the user. On success, replace the optimistic value with the server's response, which may differ (server-assigned IDs, normalized text, counts changed by others). With several mutations in flight, roll back only your own change or refetch, so a failure does not overwrite a later success.
Client-server contracts. fetch resolves for any HTTP status; only network failures and aborts reject, so check response.ok and parse error bodies deliberately, and validate response shapes at runtime. Separate retryable failures (timeouts, 503, 429 with Retry-After) from permanent ones (400, 403, 404, 409). GET, PUT and DELETE are idempotent by HTTP semantics; POST is not, so retry a create only when the API accepts an idempotency key that lets the server deduplicate. Back off exponentially with jitter. Offset pagination is simple but skips or repeats rows when items are inserted or deleted between pages; cursor pagination continues from a stable position.
Common misconceptions
- "Debouncing prevents race conditions." It reduces the number of requests; overlapping requests can still resolve out of order.
- "fetch rejects on HTTP errors." It resolves for 404 and 500; you must check
response.ok. - "Aborting a request undoes it on the server." Abort stops the client from waiting; the server may already have applied a mutation, which is why idempotency matters.
- "Storing derived values is a harmless optimization." Every stored copy is another place to forget an update; compute and memoize instead.
- "Optimistic UI just means updating early." Without a snapshot, a rollback path and reconciliation with the real response, it turns failures into silently wrong screens.
How to approach it
Build the scenarios instead of only reading about them: a search box against a local server with random delays, a like button whose endpoint sometimes fails, and a paginated list with inserts between page loads. Graduate interviews tend to ask about state placement and async basics; mid-level and senior frontend interviews present a race or consistency bug and expect a mechanism, its failure modes and the API support it needs.
Self-check before you finish
- In a filterable table, which pieces of state are sources of truth and which are derived?
- What belongs in the cache key of a search-results query?
- What happens to the server-side effect of a POST when the client aborts it?
- How does a sequence token differ from aborting the previous request, and why combine them?
- Why can offset pagination show the same row twice?
- What must you snapshot before applying an optimistic update?