Fetching and caching data on the client
A network request looks like one line of code, but it has several possible outcomes, can arrive out of order, and may already be out of date. Most data-fetching bugs come from handling only the happy path.
Every request has four states
Design the UI for each of them:
- Loading. Show a skeleton shaped like the content, not a spinner that jumps the layout. If the data usually arrives quickly, delay the indicator slightly so it doesn’t flash.
- Error. Say what failed and offer a retry. Keep any data you already had on screen.
- Empty. “No invoices yet” with a next step is different from an error, and different from still loading.
- Success. The normal view.
Distinguish the first load from a background refresh; a refresh needs only a small indicator, not a blank table.
Remember that fetch only rejects on network failure. A 404 or 500 resolves normally, so check the status yourself:
const res = await fetch(url, { signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
Race conditions and stale responses
Type “re” then “react” into a search box and two requests go out. If the first one is slower, it arrives last and overwrites the correct results. The same happens when a user clicks quickly between tabs or pages.
Cancel the previous request when its input changes, using an AbortController. In React, an effect’s cleanup is the natural place:
useEffect(() => {
const controller = new AbortController();
search(query, controller.signal)
.then(setResults)
.catch((err) => {
if (err.name !== "AbortError") setError(err);
});
return () => controller.abort();
}, [query]);
Aborting also frees the connection. If you can’t abort (a library that doesn’t accept a signal, say), track the latest request id and ignore any response that isn’t from it.
Caching and revalidation
Keep a client cache keyed by request (for example ["invoices", { page: 2 }]) and use stale-while-revalidate: show the cached value immediately, fetch a fresh copy in the background, and update the screen if it changed.
Decide, per kind of data, how long it counts as fresh. Reference data such as a country list can stay fresh for hours; a notification count might be stale after seconds. Common triggers to revalidate are window focus and network reconnection.
After a mutation, invalidate the affected cache entries so they refetch, rather than patching many copies by hand. Libraries such as TanStack Query and SWR implement caching, deduplication of identical requests, revalidation and cancellation. Use one unless your needs are very small.
Optimistic updates and rollback
For actions that almost always succeed (liking a post, ticking a to-do), update the UI before the server answers. This is an optimistic update. It makes the interface feel instant, but you must handle failure:
- Cancel in-flight refetches of the same data so they don’t overwrite your change.
- Save a snapshot of the current value.
- Apply the change locally.
- On error, restore the snapshot and tell the user.
- Either way, revalidate once the request settles.
Avoid optimistic updates when failure is common or costly, such as payments or anything the server validates in complex ways.
Pagination and infinite lists
Offset pagination (?page=3) is simple and allows jumping to a page, but items shift when rows are added or deleted, so users see duplicates or miss rows. Cursor pagination (?after=abc123) asks for “items after this one” and stays stable as data changes; prefer it for feeds and large tables.
For infinite scroll, load the next page when a sentinel element near the bottom becomes visible, detected with IntersectionObserver, and also offer a “Load more” button for keyboard and screen reader users. Keep the loaded pages and scroll position when the user navigates back. Once lists reach thousands of rows, render only the visible ones with a virtualised list.
Checklist
- Loading, error, empty and success states designed and tested.
res.okchecked; errors keep existing data visible.- Stale requests aborted or ignored.
- Mutations invalidate the right queries.
- Optimistic updates roll back on failure.
- Cursor pagination for data that changes often.