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.- 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'sgetSnapshot) — 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/useLayoutEffectand raw async primitives (fetch/Promise,setInterval/setTimeout,addEventListener, hand-rolledsubscribe) — 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 auseQuery/useSWRhook 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-depsnit 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
- React —
useSyncExternalStore(getSnapshot"returns a snapshot of the data in the store";subscribereturns an unsubscribe): https://react.dev/reference/react/useSyncExternalStore - React — Synchronizing with Effects (the race-condition
ignore-flag cleanup; "fetch needs either cancel or ignore"; StrictMode dev double-fetch is harmless): https://react.dev/learn/synchronizing-with-effects - React —
<StrictMode>(dev-only extra setup+cleanup cycle to find missing cleanup; theuseRef-to-block-the-second-run anti-pattern): https://react.dev/reference/react/StrictMode - React —
useEffectreference (cleanup, subscribe/unsubscribe, dependency semantics,Object.iscomparison): https://react.dev/reference/react/useEffect - React — Separating Events from Effects (
useEffectEventto read the latest reactive value from an interval/subscription without restarting it; non-reactive, omit from deps): https://react.dev/learn/separating-events-from-effects - React — Removing Effect Dependencies (functional updater, move the declaration inside, do not lie to the linter): https://react.dev/learn/removing-effect-dependencies
- React — Lifecycle of Reactive Effects (each effect captures the values from its render; effects synchronize, they do not "run once"): https://react.dev/learn/lifecycle-of-reactive-effects
- React —
react-hooks/exhaustive-depsESLint rule (the authoritative mechanical dependency check; suppress by restructuring, not disabling): https://react.dev/reference/eslint-plugin-react-hooks/lints/exhaustive-deps - React — You Might Not Need an Effect (effect-based fetching carries race/waterfall boilerplate; prefer a framework/cache; not every side effect needs an effect): https://react.dev/learn/you-might-not-need-an-effect
- MDN — AbortController (
abort()cancels the fetch and rejects with anAbortErrorDOMException; passsignalto the request): https://developer.mozilla.org/en-US/docs/Web/API/AbortController - MDN — AbortSignal (the signal object that communicates cancellation to an async operation): https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal
- React 18 — the "setState on an unmounted component" warning was removed (false positives outweighed the signal): https://github.com/facebook/react/pull/22114