SuperAPI AI Directory

async-effect-race-contracts

Frontend & websites

View repository

Updated 10-16 06:44

About this skill

Async effect race contracts

A raw useEffect that touches the outside world (fetch, subscribe, listen, schedule) owes two things the happy path never shows: a take-latest guard so an older async result cannot overwrite a newer one, and a cleanup that mirrors setup exactly so nothing keeps running after the effect re-runs or the component unmounts.

Checklist (lead with the trap)

  1. Take-latest, not last-response-wins: guard every fetch-on-deps effect. Per-run let ignore = false flipped in cleanup and checked before setState, or an AbortController aborted in cleanup. ignore only discards the result; AbortController actually cancels the request — swallow its AbortError, do not surface it as a real error.
  2. Every setup that subscribes/opens/schedules needs a cleanup that undoes it — subscribe→unsubscribe, addEventListener→removeEventListener (same function reference), setInterval/setTimeout→clear*, connect→disconnect. A missing return accumulates a new listener/interval/connection on every re-run — that is the real leak; a late async setState after unmount is harmless by itself, but React 18 removed its warning, so the console no longer flags either case.
  3. StrictMode double-invoke is a stress test, not a bug — make setup idempotent, do not suppress it. Blocking the second run with a useRef guard hides the bug — the real remount on navigate-away-and-back still leaks. A side effect that should not fire on display at all (a POST, a registration) belongs in an event handler, not an effect.
  4. Stale closure: a value read inside a long-lived callback is frozen at the render that created it. An interval/timeout/subscription/event handler set up once ([]) captures that render's props and state forever, so it keeps reading the old value. In order of preference: a functional updater setX(x => ...) when you only need previous state; useEffectEvent to read the latest reactive value without restarting the timer (it is non-reactive, must be omitted from deps, and may only be called from inside an effect); or a ref you keep current (ref.current) on React without useEffectEvent. Adding the value to deps also works but restarts the timer on every change.
  5. Dependency array: omissions read stale; unstable references loop. Fix by moving the declaration inside the effect, wrapping the reference in useMemo/useCallback, or using a functional updater. Do not silence react-hooks/exhaustive-deps with a disable comment — a suppressed dep is where these bugs hide.
  6. [], no array, and [a, b] mean different things — confirm intent matches the effect. An effect with [] that reads a prop or state is a stale closure waiting to happen — pick a fix from item 4 rather than lying about the deps.
  7. A late subscriber misses a value that was already published. An async result (detected locale, geo lookup, auth state) emitted before a component subscribes is never delivered to it. After subscribing, read the current value once — or expose the source as a store with a snapshot (useSyncExternalStore's getSnapshot) — and give the subscription API a listener set plus an unsubscribe return; a single overwrite-style callback slot silently drops every subscriber but the last.

Quick probes

Treat hits as leads; open each effect and trace setup -> cleanup -> dependency array.

rg -n 'useEffect\(|useLayoutEffect\(' src/ app/ 2>/dev/null            # each effect: does it fetch/subscribe/schedule, and does it return a cleanup?
rg -n 'await |\.then\(' src/ app/ 2>/dev/null | rg -i 'effect'         # async inside an effect -> is there a take-latest guard?
rg -n 'ignore|AbortController|\.abort\(|\bsignal\b' src/ app/ 2>/dev/null  # cancellation/ignore present on the fetch path?
rg -n 'addEventListener|subscribe\(|setInterval|setTimeout|new WebSocket|createObjectURL' src/ app/ 2>/dev/null  # setup -> matching teardown in the return?
rg -n 'eslint-disable.*exhaustive-deps' src/ app/ 2>/dev/null          # suppressed dependency checks -> likely stale read or hidden bug

Route the mechanical "is every reactive dependency listed" check to the react-hooks/exhaustive-deps ESLint rule; reserve human review for whether the effect should exist at all and whether its cleanup truly mirrors setup.

Boundary with sibling skills

  • This skill: the bare useEffect/useLayoutEffect and raw async primitives (fetch/Promise, setInterval/setTimeout, addEventListener, hand-rolled subscribe) — take-latest guarding, cleanup mirroring setup, StrictMode idempotency, stale closures, and dependency arrays.
  • frontend-data-fetching-cache-contracts — when a data library (TanStack Query, SWR, RTK Query, Apollo) owns the fetch: its query cancellation, dedupe, staleness, and refetch are that layer's contract. If you are hand-writing useEffect(() => { fetch(...) }), you are here; if a useQuery/useSWR hook owns it, you are there.
  • optimistic-update-rollback-contracts — races in the apply -> confirm -> rollback -> reconcile of a mutation (a failed write that never rolls back, a temp id never swapped). A read-side fetch race is here; a write-side reconciliation race is there.
  • realtime-transport-contracts — the subscribe/unsubscribe lifecycle and recovery of a socket/SSE connection (reconnect backoff, resume cursor, heartbeat). A one-off listener/subscription cleanup inside an effect is here; connection-drop resilience is there.
  • cjk-text-and-input — whether IME composition input should trigger the fetch at all (a query fired mid-composition on isComposing/compositionend). The take-latest guard for the requests that do fire stays here.

PR-worthiness gate

File a finding only when a user-visible or resource contract is actually broken:

  • Missing take-latest: a deps-driven fetch with no ignore/abort where responses can realistically land out of order (search-as-you-type, tab/route switches) and stale data can win.
  • Missing/mismatched cleanup: a subscription/listener/interval/socket opened with no return, or a cleanup that does not undo the setup — an accumulating leak, or a late setState that visibly applies stale data (a bare setState after unmount is not a finding by itself; see item 2).
  • Non-idempotent setup exposed by StrictMode: a real duplicated side effect (two sockets, doubled analytics/POST), not merely "it runs twice in dev."
  • Stale closure with impact: a long-lived callback reading a value that visibly goes stale (a timer showing the mount-time count, a handler using an old prop).
  • Dependency bug with impact: an omission causing a stale read, or an unstable reference causing an actual re-render loop.
  • Late subscriber with impact: a consumer that subscribes after the value was emitted and visibly never updates, or a single-slot callback that drops earlier subscribers.

Reject weak findings:

  • An effect that already pairs setup with a mirroring cleanup and a take-latest guard — that is the correct pattern, not a defect.
  • "Runs twice in dev" with no observable duplicated effect — StrictMode working as intended.
  • A data-library hook (useQuery/useSWR) — cancellation and dedupe are the library's job; route to the data-fetching skill.
  • A lint-only exhaustive-deps nit with no resulting stale read or loop — let the linter own it rather than filing a review finding.
  • An effect with [] that genuinely reads nothing reactive — not a stale closure.

Minimal useful PR: one failing test — fire two dependency changes, resolve the first response last, and assert the newer result wins (or that abort fired); or mount then unmount and assert the subscription/interval was torn down and no stale result was applied.

Output shape

Return compact findings:

  • Contract: take-latest / cleanup-mirrors-setup / StrictMode-idempotent / stale-closure / dependency-array / late-subscriber.
  • Evidence: file/line and the effect's setup, its cleanup (or the missing return), and its dependency array.
  • Symptom: stale response wins, accumulating leak, doubled side effect, frozen captured value, or re-run loop.
  • Fix: smallest change — add an ignore/abort guard, add or repair the cleanup, move a POST into an event handler, use useEffectEvent/ref/updater, or correct the deps.
  • Verification: unit/integration test driving the out-of-order race or the mount/unmount teardown.

Sources