async-effect-race-contracts
Installation
SKILL.md
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)
- Take-latest, not last-response-wins: guard every fetch-on-deps effect. Per-run
let ignore = falseflipped in cleanup and checked beforesetState, or anAbortControlleraborted in cleanup.ignoreonly discards the result;AbortControlleractually cancels the request — swallow itsAbortError, do not surface it as a real error. - 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 asyncsetStateafter unmount is harmless by itself, but React 18 removed its warning, so the console no longer flags either case. - StrictMode double-invoke is a stress test, not a bug — make setup idempotent, do not suppress it. Blocking the second run with a
useRefguard 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. - 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 updatersetX(x => ...)when you only need previous state;useEffectEventto 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 withoutuseEffectEvent. Adding the value to deps also works but restarts the timer on every change. - 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 silencereact-hooks/exhaustive-depswith a disable comment — a suppressed dep is where these bugs hide. [], 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.
Quick probes
Treat hits as leads; open each effect and trace setup -> cleanup -> dependency array.