In a React component, a refresh button and a 30-second poll both call the same async loader while the component stays mounted, so no effect cleanup runs between the two in-flight requests. How do you guarantee that only the newest response is written to state?
answer
- no cleanup fires between the two calls
- the run cannot identify the request
- a shared scoreboard, a private ticket
- increment on start, compare on resolve
- match the response to its request
basics
~20 sStamp each request with an incrementing id kept in a ref, capture that id in the call, and write state only when it still equals the latest id. Anything older is discarded, so responses are matched to the request that asked for them.
solid answer
~50 sThe cleanup-flag pattern only helps when the effect re-runs, and here nothing re-runs — two calls of the same loader overlap inside one mounted component. So the discriminator has to be a request identity rather than a per-run flag. Keep a counter in a ref, increment it at the start of every call, capture the value in a local `const id`, and when the promise resolves write state only if `id === requestId.current`. A late poll response finds a newer id, drops itself, and the manual refresh wins. Guard the error and loading writes with the same check. If the request has a natural key — the query string, the record id — you can compare that instead, but a counter also handles the case where the same key is requested twice. This is the general form of the ignore flag: the flag is the degenerate case where the identity is just "my run".
code
javascript · 25 linesimport { useCallback, useEffect, useRef, useState } from 'react';
export function useLatestResult(load) {
const requestId = useRef(0);
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const run = useCallback(async () => {
const id = ++requestId.current;
try {
const result = await load();
if (id === requestId.current) setData(result);
} catch (err) {
if (id === requestId.current) setError(err);
}
}, [load]);
useEffect(() => {
run();
const timer = setInterval(run, 30000);
return () => clearInterval(timer);
}, [run]);
return { data, error, refresh: run };
}go deeper
Recall that two calls of the same async function can be in flight at once and that whichever finishes last writes state. Know that a ref keeps a value that survives renders.
Explain why the effect-cleanup flag has nothing to flip here, and write the counter version: increment into a local const at the start, compare against the ref after awaiting, and guard the error write the same way.
Show the judgment of choosing a discriminator — counter versus request key — and name the duplicate-key gap. Demonstrate that you guard error and loading writes too, since a stale rejection blanking a good screen is the failure users actually report.
Argue for one loader that stamps and matches every request, so components cannot each invent their own scheme. Be clear that this buys correctness only, and that reducing request volume is a separate decision that never removes the identity check.
## Why the usual fix does not apply The `let ignore = false` pattern relies on React running the previous effect's cleanup before starting the next run. That cleanup is the thing that retires the older request. In this scenario the effect does not re-run at all: the poll's timer and the button handler both invoke a loader while the component sits still. Two requests overlap and no cleanup fires between them, so there is no moment at which the older one can be marked obsolete by a flag. ## Make the identity explicit When the run cannot identify a request, the request has to identify itself. A monotonically increasing counter in a ref is the simplest way: ```js const requestId = useRef(0); const run = async () => { const id = ++requestId.current; // this call's identity const result = await load(); if (id !== requestId.current) return; // a newer call has started setData(result); }; ``` `id` is a `const` in the function's own scope, so every invocation has its own; `requestId.current` is shared and always names the newest call. Comparing them at resolve time asks exactly the right question — *am I still the request the UI is waiting for?* — and it answers correctly no matter how many calls overlap or in what order they finish. The ref is the right storage here precisely because it must be shared: it is a scoreboard, not a per-call flag. That is the mirror image of the effect case, where sharing the flag was the bug. ## Guard every write, and think about failures Apply the check before writing data, before writing an error, and before clearing a loading indicator. A stale rejection is the nastier version of this bug: a poll that times out after a successful manual refresh will blank a perfectly good screen if its `catch` writes unconditionally. For loading state, note that with a counter you can render "is anything outstanding" honestly by tracking a small count of in-flight calls, or simply by treating loading as owned by the newest request and letting stale ones skip the `setLoading(false)`. ## Keying to the request instead of counting Sometimes the request already carries a natural identity: the search term, the entity id, the filter object serialised to a key. Then you can compare the response's key with whatever the UI currently wants: ```js const data = await load(query); if (query !== latestQuery.current) return; ``` This reads well and doubles as cache-key thinking. Its one weakness is duplicates: if the same key is requested twice (a manual refresh of the record you are already viewing), the two responses are indistinguishable, so a stale one can still land — usually harmless because the payload is for the right key, but not always if the data changed in between. A counter has no such ambiguity, and using both — key for cache identity, counter for ordering — is perfectly reasonable. ## Interaction with the effect-run flag If the component *also* re-fetches when a dependency changes, you now have two sources of obsolescence: dependency changes (cleanup) and newer manual calls (counter). Rather than reasoning about both, route everything through the loader that stamps ids, and let the effect call that loader. Then the counter alone decides who wins, and the effect cleanup is only needed for genuinely run-scoped teardown such as clearing the interval. ## What this does not solve Only correctness. Every request still travels, so a user who mashes refresh generates a request per click and the server sees all of them. Reducing that volume — cutting requests short, coalescing identical in-flight calls, or spacing them out — is a separate decision, and none of those techniques removes the need for the identity check, because a response can already be in the microtask queue by the time you decide you no longer want it. ## In an interview Say the general principle rather than the recipe: *a response must be matched to the request that asked for it.* The ignore flag and the request counter are two encodings of the same rule, and knowing which one fits follows directly from whether an effect run defines the request or not.
- Could you compare the request's own key — the query string or record id — instead of a counter?Yes, and it reads nicely because the key doubles as cache identity. The gap is duplicates: two calls with the same key are indistinguishable, so a stale one can still land. That is usually harmless since the payload matches the current key, but a counter removes the ambiguity outright.
- Why is a ref the right place for the counter when a ref was the wrong place for the ignore flag?Because the roles are inverted. The ignore flag had to be private to one effect run, so sharing it broke it. The counter must be shared — it is the scoreboard naming the newest request — while the per-call identity lives in a local const. Shared value plus private copy is the whole pattern.
- What if the effect also re-fetches on a dependency change?Route both paths through the same stamping loader. Then the counter alone decides which response wins, and the effect's cleanup is left to do genuinely run-scoped teardown such as clearing the interval. Reasoning about one discriminator is far safer than composing two.
saying these in an interview costs you the question
- Reaches for the ignore flag when no cleanup runs
- Stores the per-call id in shared state instead of a local
- Compares ids before awaiting rather than after
- Leaves the catch branch unguarded against stale errors
- Assumes a poll and a manual refresh cannot overlap