Three spinners for one dish
The menu page shows the dish name in the header, the card and the cart. Each component fetches it in its own useEffect: three requests, three spinners, and when you come back to the page, three more. The data is identical every time. Multiply that by every screen in a large app and the server, the network and the user all pay.
The field guide's data-fetching chapter draws the key distinction: server state isn't like a checkbox's state. It lives on a server, other people can change it, and your copy can go stale. That's why libraries like React Query (TanStack Query), SWR and Apollo exist — each is a cache with rules. Today you'll build the core of one, so those libraries stop being magic.
A cache with three rules
- Deduplicate: if a request for this key is already in flight, return its promise instead of starting another — that's request deduplication.
- Serve fresh data from the cache: data younger than staleTime is returned without a request. React Query's default staleTime is 0 (always refetch in the background); the book shows setting it to a minute.
- Stale-while-revalidate: when data is stale, keep showing it while a background refetch runs, then swap in the new data. The name comes from an HTTP caching directive.
- Invalidate after a change: invalidate(key) marks data stale so the next read refetches — cache invalidation.
function fetchQuery(key, fetcher) {
const entry = cache.get(key);
if (entry?.promise) return entry.promise;
if (entry?.status === "success" && Date.now() - entry.updatedAt < STALE_TIME)
return Promise.resolve(entry.data);
const promise = fetcher().then(
(data) => { setEntry(key, { status: "success", data, updatedAt: Date.now(), promise: null }); return data; },
(error) => { setEntry(key, { status: "error", error, promise: null }); throw error; },
);
setEntry(key, { status: entry?.status ?? "loading", promise });
return promise;
}