Two React components each fetch /api/user in their own useEffect and keep the result in their own useState. What problems does duplicating that server data create, and what changes if both read one shared cache entry instead?
answer
- same fact stored twice
- no relationship between the copies
- one entity, one address
- who do you tell after a write
- key by entity, not by component
basics
~20 sEach copy fires its own request, ages independently, and is updated independently, so the two components drift apart and there is no single place to invalidate after a write. One entry keyed by the entity, shared by both readers, gives one request, one truth and one invalidation point.
solid answer
~50 sDuplicating it means the same entity exists twice with no relationship between the copies. You pay two requests on mount, you get two independent loading and error states, and — the real bug — after a write only the component that performed it updates, so the header keeps showing the old name while the profile page shows the new one. There is also nowhere to say "this user is now stale": invalidation would have to reach into every component that happens to hold a copy. The fix is to make the entity, not the component, the unit of storage: one entry keyed by something like `user/42`, with components subscribing to it. Then a request in flight is deduplicated, an update writes once and every subscriber re-renders, and invalidation is a single operation against that key.
code
javascript · 33 lines// A minimal shared cache: keyed entries + subscribers, outside the tree
const entries = new Map(); // key -> { data, promise }
const listeners = new Set();
function emit() {
listeners.forEach((fn) => fn());
}
export function read(key, fetcher) {
let entry = entries.get(key);
if (!entry) {
entry = { data: undefined, promise: null };
entries.set(key, entry);
}
if (entry.data === undefined && !entry.promise) {
entry.promise = fetcher().then((data) => {
entry.data = data;
entry.promise = null;
emit();
});
}
return entry.data;
}
export function write(key, data) {
entries.set(key, { data, promise: null });
emit(); // every subscriber sees the new value, not just the writer
}
export function invalidate(key) {
entries.delete(key);
emit();
}go deeper
Be able to spot that both components request the same URL and hold separate copies, and to say what the user sees when only one of them is updated after an edit.
Name the failure modes in order — duplicate requests, independent ages, divergence after a write, no invalidation point — and explain that keying storage by entity instead of by component instance removes all of them at once.
Argue for where the cache lives: outside the tree, keyed, with a lifetime independent of mounting, so back-navigation is instant and non-render code can invalidate. Say what you would write in yourself versus adopt, and why.
Own it as a codebase-wide contract: a key convention per entity, a rule that mutations invalidate keys rather than patching component state, and a review standard that blocks new per-component copies of shared server data.
## The shape of the mistake ```jsx function Header() { const [user, setUser] = useState(null); useEffect(() => { fetch('/api/user').then((r) => r.json()).then(setUser); }, []); return <span>{user?.name}</span>; } function ProfilePanel() { const [user, setUser] = useState(null); // a second, unrelated copy useEffect(() => { fetch('/api/user').then((r) => r.json()).then(setUser); }, []); // ...renders an edit form } ``` Nothing here is syntactically wrong, and in a demo it works. What is wrong is the *unit of storage*: the copy is scoped to a component instance, while the thing being stored is a server entity that has exactly one true value. ## What breaks, concretely **Duplicate work.** Both components mount, both request the same URL. With five widgets on a dashboard you get five requests for the same row, five parses, five renders. **Independent ages.** The copies were fetched at different moments and are refreshed on different schedules. One may be minutes older than the other, and nothing in the code expresses that they should agree. **Divergence after a write.** This is the one users report. `ProfilePanel` submits a name change and calls its own `setUser`. `Header` never hears about it and keeps rendering the old name until something happens to remount it. The screen is now internally inconsistent — showing two values for one fact — which reads as a broken app even though every individual component is "correct". **No invalidation point.** Suppose a websocket message says the user changed server-side. Which state do you update? There is no address for "the user" — only N private copies inside N component instances, reachable from nowhere. **Unmount amnesia.** Each copy dies with its component, so navigating away and back re-fetches from scratch and shows a spinner for data that was correct two seconds ago. **N loading states.** Each copy renders its own skeleton at its own time, so the page assembles itself in a stutter rather than in one coordinated step. ## What one shared entry changes Make the *entity* the unit of storage. A cache is a map from a key — `'user/42'`, or a structured key like `['user', 42]` — to `{ status, data, error, updatedAt }`, plus a set of subscribers. Then: - **Deduplication.** A second component asking for a key that already has a request in flight joins that request instead of starting another; the promise is stored under the key. - **One truth.** Every subscriber renders the same object, so "the header and the profile disagree" becomes structurally impossible. - **One write path.** A successful mutation writes the server's response into the key once, and every subscriber re-renders. - **One invalidation.** Marking `user/42` stale is a single operation, independent of who happens to be rendering it. - **A lifetime you choose.** The entry can outlive the components that read it, so a back-navigation renders instantly from the cached value while a refresh happens in the background. ## The two ways teams get there The first step most codebases take is to **lift the fetch** into a common ancestor and hand the value down — through props, or through a provider so deep consumers do not have to be threaded. That alone kills the duplicate requests and the divergence, because there is again one copy. It does not give you keys, per-entity lifetime, deduplication across unrelated subtrees, or background revalidation; and the value is now pinned to the lifetime of a component high in the tree. The second step is a **cache that lives outside the tree** — a module-scoped map that components subscribe to, which is exactly what React's `useSyncExternalStore` exists to let you do safely. Once the cache is outside the component tree, the entry's lifetime, its keys and its invalidation are no longer tied to who is rendering. This is the structure every server-cache library implements; whether you adopt one or write the eighty lines yourself is a separate decision from getting the *shape* right. ## How to say it in an interview The sentence that lands is: *the bug is not the fetching, it is that the same fact is stored twice with no relationship between the copies.* Everything else — duplicate requests, drift after a write, nowhere to invalidate — follows from that one structural choice, and all of it is fixed by keying the data by entity rather than by component instance.
- Does lifting the fetch into a shared parent solve the problem completely?It solves duplication and divergence, because there is one copy again. It does not give you entity keys, deduplication across unrelated parts of the tree, a lifetime longer than the parent component, or background revalidation — the copy still dies when the parent unmounts, so a back-navigation refetches from zero and shows a spinner.
- After a successful PATCH, is it better to write the response into the cache or to refetch the key?Writing the server's response in is cheaper and avoids a flash, and it is safe when the response returns the full updated entity. Refetching is safer when the write has side effects on other fields or other entities the response does not include. Many teams do both: write in for the immediate render, then revalidate in the background.
- Why should the cache live outside the component tree rather than in a provider's state?Because the entry's lifetime and identity then stop depending on who is rendering. Entries survive unmounts, unrelated subtrees can share a key without a common ancestor, and non-render code — a websocket handler, a mutation helper — can invalidate a key without needing a React context. Components subscribe to it via `useSyncExternalStore`.
saying these in an interview costs you the question
- Says duplicate fetches are only a performance problem
- Fixes divergence by remounting or reloading the page
- Thinks each component should own its own copy of an entity
- Claims putting it in context gives you caching
- Updates only the component that performed the write