skip to content

Three components on the same React screen each call a custom `useUser()` hook that fetches `/api/user` inside its own `useEffect`. Why does the page send three identical requests, and why does navigating away and back send three more?

level: middleimportance: should knowfreq 55%

answer

  1. hooks are per instance
  2. the effect has no key
  3. unmount discards the state
  4. dedupe needs shared identity
  5. freshness policy nobody wrote

basics

~20 s

State and effects live per component instance, and no instance can see what another has requested, so each mount issues its own request. Unmounting throws that state away, so returning to the screen mounts fresh instances that fetch again. Effects have no cache and no dedupe.

solid answer

~50 s

An effect and the state it writes to belong to one component instance. Three components mean three independent copies of the hook, each with its own `data` slot and its own effect, and none of them can observe that another has already asked for the same URL — the effect has no identity for the request beyond the closure it runs in. Unmounting the screen discards that state entirely, so navigating back mounts new instances whose effects fire again from scratch. Deduplication and reuse need something the components share: a cache that lives outside the tree, keyed by the request, so a second caller joins an in-flight request or reads a stored result instead of starting a new one, plus a policy for when that entry is stale. You can hand-build a narrow version by lifting the fetch into one ancestor, but you inherit the cache-invalidation problem.

go deeper

for a junior

Know that each component instance has its own state and its own effect, so three components calling the same hook do the work three times. Say plainly that a custom hook shares logic, not data.

for a middle

Explain why an effect cannot dedupe — it has no key or identity for the request — and why unmounting discards the result. Describe what lifting the fetch into one ancestor fixes and what it leaves unsolved.

for a senior

Bring up consistency, not just request count: independent copies of the same entity drift apart after a mutation. Be ready to name what a shared cache must decide — freshness, revalidation triggers, invalidation after writes — and why the HTTP cache does not cover it.

for a principal

Treat it as ownership of the server-state layer: decide whether the app has one cache with a documented staleness contract or dozens of ad-hoc copies, and be able to justify the dependency, the migration path, and the failure modes of getting invalidation wrong.

## Why three components means three requests Hooks are per instance. When React mounts a component it allocates that instance's own hook state, and `useEffect` schedules a callback tied to that instance. `useUser()` extracted into a custom hook does not create shared state — a custom hook is just a function that calls hooks, so calling it in three components produces three independent sets of `useState` slots and three separate effects. Critically, the effect has no identity for the work it does. React sees a function to call after commit. It does not know the function will hit `/api/user`, cannot compare that URL against anything, and has no table in which to record "this request is already in flight". Deduplication requires knowing that two requests are *the same request*, and only a layer that models requests as keyed entries can know that. ## Why navigating away and back refetches Unmounting destroys the instance's hook state. There is no persistence layer under `useState`: the fetched object becomes garbage as soon as nothing references it. Remounting gives you a brand-new instance whose `data` starts at its initialiser, and the mount effect fires again. From React's perspective this is correct behaviour, not a leak — it is just that "correct" here means "forgets everything". The HTTP cache sometimes hides this. If the response carries cache headers the browser may serve the repeat request from disk, which makes the problem invisible in development and very visible on an endpoint that sends `Cache-Control: no-store`. Never rely on it: the HTTP cache is not under your control, does not dedupe concurrent in-flight requests reliably, and does nothing for a POST-shaped API or a GraphQL endpoint. ## What a shared cache adds Moving the fetch behind a cache that lives outside the component tree changes the model from *component owns a request* to *component reads a key*: - **In-flight dedupe** — three simultaneous readers of the same key join one request instead of starting three. - **Reuse across mounts** — returning to a screen renders the stored value immediately, often while a background refresh runs. - **A single copy of the truth** — three components rendering the same entity cannot drift apart, which they easily can when each holds its own `useState` copy. - **An explicit staleness policy** — how long a value is considered fresh, whether to revalidate on focus or reconnect, and how a mutation invalidates related entries. - **Retry and error policy in one place**, rather than reimplemented per component. That list is the honest answer to "why not just use `useEffect`": not that effects are wrong, but that everything above is a cache concern, and a cache is exactly what a per-instance effect cannot be. ## The in-React middle ground and its cost The obvious first move is to lift the fetch: one ancestor fetches, and the three children read the value through props or context. That genuinely fixes the triple request while the screen is mounted, and it is the right answer for a small app. What it does not give you is anything about *time*. You now own the questions a cache exists to answer: when is this value too old to show, what refetches it after a mutation elsewhere in the app, what happens when two screens want the same entity under different providers, and what keeps the data alive across an unmount. Answering those inside your own provider is how teams end up writing a cache accidentally, one requirement at a time — which is the usual justification for adopting a purpose-built data layer instead. ```jsx // Per instance, and therefore per request: function useUser() { const [user, setUser] = useState(null); useEffect(() => { fetch('/api/user').then((r) => r.json()).then(setUser); }, []); return user; } ``` ## A related trap Because each copy holds its own snapshot, a mutation made through one component does not update the other two. Each one keeps rendering the value it fetched, and the screen shows the same entity in two different states until something forces a remount. That inconsistency is often what finally pushes a codebase off per-component fetching — it reads as a data bug rather than an architecture choice.

  • Doesn't the browser's HTTP cache already deduplicate these requests?
    Only sometimes, and never dependably. It applies to cacheable GETs with the right headers, it is controlled by the server rather than your app, and it does nothing for endpoints sending no-store, for POST-shaped or GraphQL APIs, or for concurrent in-flight requests you want collapsed. It also cannot give you application-level behaviour such as invalidating an entity after a mutation. Treat it as a bonus, never as your caching strategy.
  • Two components each hold their own copy of the same user object, and one of them saves an edit. What does the user see?
    Stale data on screen. The component that saved re-renders with the new value; the others keep rendering the snapshot their own effect fetched, so the same entity appears in two states at once until something remounts them. A single shared entry keyed by the resource removes the possibility, which is why a cache is as much a consistency mechanism as a performance one.
  • When is lifting the fetch into a single ancestor the right answer rather than adopting a data layer?
    When the sharing is local and the lifetime is the screen: a handful of components under one parent, one resource, no cross-screen reuse, and no requirement to refetch on focus or after unrelated mutations. That is a few lines of code and no dependency. The moment you start writing rules about staleness, invalidation after mutations, or surviving unmount, you are building a cache and should use one.

saying these in an interview costs you the question

  • Says React deduplicates identical fetch calls automatically
  • Assumes a custom hook shares state between components
  • Relies on the browser HTTP cache as the app's cache
  • Thinks an empty dependency array makes the fetch happen once per app
  • Claims unmounting preserves state for the next mount

context