A React developer tries to stop out-of-order fetch responses by keeping const isMounted = useRef(true), only calling setState when isMounted.current is true, and tracking a loading boolean in state. Why does neither guard prevent an older response from overwriting newer data when the effect re-fetches on a prop change?
answer
- wrong question being asked
- mounted for both responses
- one shared cell, two requests
- scoped to the component, not the run
- identity, not a boolean
basics
~20 sBoth guards are component-scoped, not request-scoped. The component is still mounted when the late response lands, and one shared loading flag cannot say which request a response belongs to, so the stale write passes. The guard must live inside a single effect run.
solid answer
~50 sThe race happens between two requests from the same mounted component, so a mounted check passes for both of them — it only ever protects against writing after unmount, which is a different (and much less harmful) problem. The `loading` boolean fails for the same reason: there is one of it, shared by every request, so when the older response arrives it sees whatever the newer request set and has no way to know it is not the request that flag belongs to. What distinguishes the two responses is *which effect run started them*, so the guard has to be scoped to a run: a `let ignore = false` declared inside the effect body, flipped by that run's cleanup, gives each request its own private boolean. Keep `loading` for the spinner — it is presentation state, not a correctness mechanism.
code
javascript · 20 linesimport { useEffect, useRef, useState } from 'react';
export function useProduct(productId) {
const isMounted = useRef(true);
const [product, setProduct] = useState(null);
useEffect(() => () => {
isMounted.current = false;
}, []);
useEffect(() => {
fetch('/api/products/' + productId)
.then((res) => res.json())
.then((data) => {
if (isMounted.current) setProduct(data);
});
}, [productId]);
return product;
}go deeper
Recall that a ref holds one value for the whole component while a variable declared inside an effect is created again on every run, and that the race involves two requests from a component that never unmounted.
Trace the two-request timeline out loud and show precisely where the shared flag gives the wrong answer. Then state the rule: the guard must be scoped to the run that started the request, which is why it is a local variable plus a cleanup.
Separate the two problems explicitly — writing after unmount versus overwriting fresh data — and say which one actually damages the UI. Be ready to redesign a shared hook so it compares a request identity instead of reading a boolean.
Own the review heuristic: any component-level flag guarding a per-request write is a smell, because scope mismatch is the recurring defect. Push the discrimination into one shared loader that stamps and matches requests, so individual components cannot get the scoping wrong.
## Two different failure modes get conflated There are two things people try to prevent with a flag, and they are not the same: 1. **Writing state after the component unmounted.** Harmless in practice — React ignores the update — and it is the reason the old "can't perform a React state update on an unmounted component" warning was retired. 2. **Writing an older request's result over a newer one while the component is very much alive.** A real correctness bug: the screen shows data that does not match the current props. An `isMounted` ref addresses only the first. In the race, both responses arrive while the component is mounted, so `isMounted.current` is `true` for both and the guard waves the stale one through. ## Why the loading boolean also fails The reasoning behind it is usually: *set `loading` before each request, clear it when a response arrives, and skip the write if we are not loading.* Trace it with two requests. Request A starts, `loading = true`. Props change, request B starts, `loading = true` again. B resolves, writes data, `loading = false`. A resolves — and if it checks `loading` it sees `false` and may skip, or, more commonly, the check was never there and it simply writes. Either way the boolean carries no identity. One shared cell cannot answer the question that actually matters: *is this response the one the current UI is waiting for?* The same defect shows up in every component-level variant: a `useRef(false)` "in flight" flag, a `useState` status string, a module-level `let busy`. They are all one value shared by every request the component ever makes. ## Scope is the whole insight The two responses differ by the effect run that started them. So the discriminator must be created per run: ```js useEffect(() => { let ignore = false; // fresh binding, this run only load(productId).then((data) => { if (!ignore) setProduct(data); }); return () => { ignore = true; }; }, [productId]); ``` Each run's `.then` closure captures that run's `ignore`. React runs the previous run's cleanup before starting the next one, so exactly the obsolete runs get flipped to `true`, and the current run is untouched. Nothing is shared, so nothing can be confused. This is also why moving the flag into `useRef` "to keep it stable" breaks it. Stability is precisely what you do not want: a ref holds one value across all runs, so run 2 setting it to `false` un-flags run 1's stale response. ## When a ref is the right tool anyway A ref works if you stop storing a boolean and store an *identity* instead — a request counter or the key of the request currently owned by the UI — and compare at resolve time rather than reading a shared true/false. That is the shape you need when requests are not started by effect runs, so no cleanup fires between them. ## Where the loading boolean still belongs Keep it, but for what it is good at: rendering a spinner, disabling a submit button, showing a skeleton. It is presentation state derived from "is anything outstanding", and it does not need per-request identity to do that job. Just be careful that an obsolete response does not clear it — put the `if (!ignore)` check in front of the `setLoading(false)` too, or an older request finishing last will hide the spinner while the newer request is still running. ## How to say it in an interview Name the axis: component-scoped versus request-scoped. A mounted check and a loading flag answer questions about the component; the race is a question about a request. Once you frame it that way the fix follows without memorising a pattern.
- So is a loading boolean useless here?Not useless — just not a correctness tool. It is presentation state: spinners, skeletons, a disabled submit button. Keep it, and guard the write that clears it so an obsolete response cannot hide the spinner while the current request is still outstanding.
- The team wants one shared guard inside a custom hook rather than a flag copied into every effect. What has to change?It cannot be a boolean. A hook that owns requests across runs has to store an identity — a monotonically increasing request id, or the key of the request the UI currently wants — capture it when the call starts, and compare it at resolve time. Comparison replaces the shared flag.
- Does an unguarded state write after unmount actually hurt anything?On its own, no: React drops the update, and the old development warning about it was removed precisely because it produced false alarms. The reason to keep a guard is the stale-overwrite bug, which happens while the component is fully mounted and does corrupt what the user sees.
saying these in an interview costs you the question
- Thinks a mounted check prevents out-of-order responses
- Says the component unmounts between the two requests
- Moves the ignore flag into useRef for stability
- Believes one loading boolean identifies its own request
- Treats the unmounted-update warning as the real bug