skip to content

At what point does handling server data with plain React state — useState plus effects, or a value lifted into a provider — stop being the right home, and what would you weigh before adopting a dedicated caching layer?

level: principalimportance: should knowfreq 36%

answer

  1. it depends on obligations, not lines
  2. second reader, second screen to invalidate
  3. you are building a cache either way
  4. different problem from a client store
  5. coverage, discipline, exit cost

basics

~20 s

It stops being right once the same entity is read from several places, writes must invalidate other screens, and freshness matters — because at that point you are reimplementing keys, deduplication, revalidation and cache lifetime by hand, inconsistently, in every feature.

solid answer

~60 s

The tipping point is not volume of code, it is the arrival of the second and third obligation. One screen reading one endpoint once is genuinely fine in local state. It stops being fine when the same entity is read from several places and must agree, when a mutation in one feature has to invalidate data another feature is showing, when the data needs a lifetime longer than the component that fetched it, or when freshness policy differs per data type. Those are the parts of a cache, and hand-rolling them means each feature invents its own version. Before adopting a library I would weigh what fraction of screens are actually cache-shaped, whether the framework already removes the problem for a chunk of them by rendering that data on the server, the bundle and learning cost, and — the real one — whether the team will keep key conventions and invalidation discipline consistent. The failure I would rule out first is treating a client-state store as the answer, which gives you distribution without any freshness model.

go deeper

for a junior

Be able to say that plain state is fine for a screen that reads one endpoint once, and that things get harder as soon as several places need the same data to agree.

for a middle

Name the obligations that a cache covers — keys, deduplication, revalidation, invalidation after writes, lifetime beyond the component — and recognize when a codebase is reimplementing them per feature.

for a senior

Argue the adoption case with evidence: which bug classes are recurring, what share of data is cache-shaped, what the in-house minimum would cost, and how you would confine the dependency behind per-domain hooks.

for a principal

Own the boundary as policy: server cache and client store are separate homes, key conventions and invalidation-on-write are review-enforced, and adoption is justified by coverage and discipline rather than feature lists. Say how you would revisit the call as the app's server-rendered share grows.

## The question behind the question An interviewer asking this is not looking for a library recommendation. They want to hear whether you can name what a cache layer *does*, recognize when you have started building one accidentally, and reason about adoption cost rather than reaching for a dependency by reflex. ## What plain state is genuinely fine for Be honest about the cases that need nothing: - A screen that reads one endpoint on mount and shows it, where nothing else on the page reads the same entity. - Data that never changes during a session — a config blob, an enum list, a feature-flag payload — fetched once and held. - One-shot writes with no readers elsewhere: a contact form, a support ticket. - Anything the framework already renders on the server, which never becomes client state at all. A component that reads its data during a server render hands down finished markup, and there is nothing to cache in the browser. Adopting a caching layer for an app made mostly of these is overhead for its own sake, and saying so is part of a credible answer. ## The obligations that mark the tipping point You have crossed into cache territory once two or more of these are true: 1. **The same entity is read from more than one place** and the copies must agree. 2. **Writes in one feature invalidate data in another** — creating an item must update a list you are not currently rendering. 3. **The data needs a lifetime longer than the component** — a back-navigation should render instantly rather than spinning. 4. **Freshness policy varies per data type**, so there is no single "fetch on mount" rule that is right. 5. **Concurrent reads must be deduplicated**, because several components mount at once against the same key. 6. **The pending and error surface is repeated everywhere** — every feature hand-writing the same three-branch render. Each one individually is twenty lines. Together they are a cache, and the honest observation is that a team implements them anyway — once per feature, slightly differently, with the invalidation step usually missing. ## What to weigh before adopting one **Coverage.** What share of the app's data is actually cache-shaped? If it is three screens out of forty, the library is carried by the whole bundle to serve a corner of it. **Framework overlap.** If a meaningful part of your data is fetched during server rendering, that data never enters the client cache and the library's value shrinks accordingly. Scope the decision to what genuinely lives client-side and mutates. **Cost of the second model.** Every developer now has two mental models for data — cached remote data and local client state — plus a key convention. That is a real onboarding tax, and it is worth paying only if the alternative is each feature inventing its own. **Discipline, not features.** The thing that decides success is whether key naming and "every mutation invalidates its keys" hold across teams over a year. A library makes that discipline *expressible*; it does not make it happen. If it will not be enforced in review, the library buys less than the demo suggests. **Exit cost.** Cache access spreads to every leaf component that reads data. Confining it behind per-domain hooks keeps that reversible; scattering raw cache calls through the tree does not. ## The mistake to rule out first The most expensive wrong turn is not "hand-rolled versus library". It is putting server data into a general-purpose client-state store, because that store solves *distribution* — one value, many readers — and nothing else. It has no notion of staleness, keys, deduplication, revalidation or garbage collection, so you inherit the whole cache problem plus a layer of boilerplate to move data into it. Server data and client state are different problems, and the clean architecture keeps them in different homes: cached remote entities in a cache keyed by identity, genuinely client-owned state near its consumers or in a client store when it is truly global. ## The middle path worth naming Between "useState everywhere" and "adopt a library" sits a deliberately small in-house cache: a module-scoped map from key to entry, subscribed to from components via `useSyncExternalStore`, with three exported operations — read, write, invalidate. It is on the order of a hundred lines, it makes the obligations explicit, and it is a completely defensible answer for a mid-sized app. Its limits are equally namable: no request cancellation policy, no retry or backoff, no garbage collection of unused entries, no cross-tab coordination, no devtools. When those start to matter, that is the concrete argument for adopting something maintained — and by then you can point at the exact capabilities you need rather than the general vibe of the ecosystem. ## How to close the answer Name the tipping point as a set of obligations rather than a code-size threshold, admit the cases that need nothing, separate server-cache concerns from client-state concerns, and make the adoption argument about discipline and coverage rather than features. That is the shape of the answer an interviewer at this level is listening for.

  • Why is a general-purpose client-state store a poor home for fetched server data?
    Because it solves distribution and nothing else. It has no keys per entity, no staleness model, no deduplication of concurrent reads, no revalidation triggers and no eviction. You end up writing all of that on top of it, plus the boilerplate to move data in and out — so you have taken on a dependency and still own the whole cache problem.
  • How would you keep the option of switching or removing the cache layer later?
    Put every read and write behind per-domain hooks — one module per entity exposing the operations features actually need — so components never touch the cache API directly. Then swapping implementations is a change in a handful of modules rather than every leaf. It also gives you one place to enforce key conventions and to make sure each mutation invalidates what it should.
  • What would make you decide a hand-rolled cache is no longer sufficient?
    Concrete capability gaps rather than a feeling: needing retry with backoff, cancellation of superseded requests, garbage collection of entries nobody reads, cross-tab coordination, or inspection tooling when a stale-data bug takes a day to reproduce. When you can name three of those as actual incidents, the adoption argument writes itself.
  • Does rendering data on the server remove the need for a client cache?
    For the data it covers, yes — that data never becomes client state, so there is nothing to keep fresh in the browser. It does not remove the need for anything the user mutates and then keeps looking at, or for data that must stay live while the page is open. Scope the decision to the mutable, client-side slice rather than to the app as a whole.

saying these in an interview costs you the question

  • Adopts a caching library reflexively before naming any obligation
  • Puts fetched server data into a general client-state store
  • Thinks lifting fetch into context counts as caching
  • Judges the tipping point by lines of code rather than obligations
  • Assumes a library enforces invalidation discipline on its own

context