React · intermediate

How to verify React effect cleanup after unmount

Verifying that React useEffect cleanup actually fires and fully releases subscriptions, timers, and in-flight requests after a component unmounts. The guide treats unmount ownership as a verifiable contract, not a hope, and frames React's effect lifecycle as the boundary the developer must prove holds in production.

The symptoms

  • Console warnings about state updates on an unmounted component, or React DevTools showing a component removed while its network tab still lists pending requests attributed to it
  • Stale data flashes after navigating away from a route: timers or intervals keep ticking and call setState on a gone component, producing late DOM writes or memory growth
  • WebSocket or EventSource listeners still receive messages after the owning screen unmounts, observable as duplicate handlers or callbacks firing into nothing
  • AbortError or unhandled rejection logs that surface only when a user navigates fast, because requests issued before unmount resolve after ownership ends
  • Heap snapshots taken before and after unmounting a screen show retained closures or detached DOM nodes pointing at the removed component tree
  • Behavior is correct in development StrictMode but only manifests in production builds where double-invocation does not paper over missed cleanup

Likely causes

  • An effect creates a timer, subscription, or fetch but its returned cleanup function does not actually cancel it, so the work outlives the component
  • Cleanup is written but never returned from the effect, leaving the cleanup pair detached from React's unmount path
  • An async effect starts a promise and resolves later; cleanup runs synchronously at unmount and cannot await the in-flight request, so setState still fires
  • An AbortController is created but not passed to fetch, or is passed but the cleanup function does not call controller.abort()
  • State updates are guarded by a mounted ref that is never flipped to false in cleanup, so the guard never engages
  • Cleanup logic is registered on the wrong target, for example window instead of the component scope, or a library's teardown is not invoked inside the returned function

First ten minutes

  1. 01Pin the exact component under suspicion from the warning text, stack trace, or DevTools owner fiber; do not start refactoring until the owning component is identified
  2. 02Open the component source and locate every useEffect, useLayoutEffect, and useImperativeHandle block; mark which ones return a function and which return undefined
  3. 03For each effect, list the side effects it creates: timers, intervals, event listeners, WebSockets, EventSource, observers, and fetch or XHR calls
  4. 04Pair each created side effect with the cleanup action that should stop it, and confirm that action is reachable from the returned cleanup function
  5. 05Mount and unmount the owning screen in a controlled session and watch the console for unmount warnings, late logs, or rejected promises during the unmount window
  6. 06Capture a heap snapshot before mount and another after unmount plus a short idle, then compare retained size and detached nodes to confirm whether cleanup actually released memory

Evidence to collect

  • The unmounted component warning text and its React stack frame, used to locate the exact fiber and effect that produced it
  • A timeline of setInterval, setTimeout, addEventListener, WebSocket, EventSource, and AbortController calls with their owning component and whether cleanup touches them
  • Network panel entries for requests initiated by the suspect component, including whether they finished, errored, or were aborted after unmount
  • Heap snapshot diff showing closures retained across unmount and any detached DOM nodes still pointing into the removed subtree
  • Whether development StrictMode is active, since double-invocation only catches missed cleanup when the effect is re-run, not when a real unmount skips cleanup

Where to look

  • The boundary between React's commit phase and JavaScript's event loop, where cleanup runs synchronously but async work resolves later
  • The fiber tree in React DevTools, where the owning component's hooks list reveals which effects exist and which returned cleanup functions
  • The browser Network panel filtered by initiator, to see whether requests started before unmount finish or are aborted after unmount
  • The Memory panel's heap snapshots, to compare retained closures and detached nodes across mount and unmount
  • The component's own cleanup boundary, the returned function from useEffect, since every cancellation must be reachable from it for the contract to hold

Diagnostic steps

  1. 01Confirm the warning, late log, or stale data is produced by a specific component by reading the React fiber and stack frame, not by assuming the nearest file is guilty
  2. 02For each useEffect in that component, verify the return value is a function that cancels the side effects created inside the effect body
  3. 03Trace timers and subscriptions to their handle and confirm the cleanup function holds the same handle reference used at creation, not a re-created copy
  4. 04For fetch and XHR, verify an AbortController is created in the effect, its signal is passed to the request, and controller.abort() runs in cleanup, so late resolutions cannot reach setState
  5. 05For library subscriptions, verify the library's teardown or unsubscribe function is invoked inside the returned cleanup, not just stored for later
  6. 06Reproduce by mounting, then immediately unmounting, then triggering any pending callback or response, and observe whether the component is still touched after ownership ends
  7. 07Compare a development StrictMode run with a production run, since StrictMode's double-invocation can mask cleanup bugs that only appear when a real unmount occurs once

Common mistakes

  • Assuming React will cancel an in-flight fetch for you; React only runs the synchronous cleanup function, so any promise that resolves later will still call setState unless you abort it yourself
  • Returning a cleanup that references a local variable captured at declaration time, so the cancel target is stale and the real subscription keeps running
  • Putting cleanup logic inside the effect body after an early return, so cleanup never runs on the path that creates the subscription
  • Guarding setState with a mounted ref that is never set to false in cleanup, which masks the warning but does not actually stop the work
  • Assuming development warnings are the whole story; in production builds, missed cleanup shows up as memory growth, duplicate handlers, or late UI flashes, not as a console warning
  • Adding cleanup only after the bug is reported, instead of treating the cleanup return value as part of the effect's contract from the first commit

Safe fixes

  • If a timer is created, return a cleanup that calls clearTimeout or clearInterval with the exact handle stored in a closure variable, and verify the handle is not reassigned before cleanup runs
  • If an event listener or WebSocket is created, return a cleanup that calls removeEventListener with the same function reference, or socket.close() and EventSource.close() on the same instance
  • If a fetch or XHR is started, create an AbortController in the effect, pass its signal to the request, and return a cleanup that calls controller.abort(), so late responses cannot trigger setState
  • If a library subscription is created, return a cleanup that invokes the library's unsubscribe or dispose function on the exact instance returned by subscribe, not a re-fetched handle
  • If async work is involved, gate the post-resolution setState on a cancelled flag flipped in cleanup, and pair that with AbortController so the network call is also stopped, not just the UI write
  • If a custom hook owns the side effect, push the cleanup responsibility into the hook so consumers cannot accidentally drop the return value, and document the contract on the hook's signature

Prove the fix

  1. 01Mount the owning screen, then unmount it, then trigger each known side effect's completion path, and observe no setState, no DOM writes, and no unmounted component warning in the console
  2. 02In the Network panel, confirm every request started by the component either completes before unmount or is aborted by cleanup, with no pending entries after the unmount frame
  3. 03Take a heap snapshot before mount and another after unmount plus a short idle, and confirm retained size and detached node count drop to baseline rather than growing with each cycle
  4. 04Repeat the mount-unmount cycle five times and confirm the console, network panel, and heap remain stable across cycles, so the fix is not a one-shot coincidence
  5. 05Verify in a production build, not just development StrictMode, that the same unmount sequence produces no warnings and no retained work, since StrictMode's double-invocation can hide real misses

Prevention and next steps

  • Treat the cleanup function as part of the effect's contract from the first commit, not an afterthought, and pair each created side effect with an explicit cancellation in the return value
  • Centralize async effects behind custom hooks that own the AbortController, the cancelled flag, and the cleanup return, so consumers cannot drop the cancellation by accident
  • Run a periodic smoke test that mounts and unmounts a representative screen in a production build and asserts no pending requests, no late logs, and no heap growth across cycles
  • Keep React DevTools open during development review of any new effect, and audit the hooks list for any effect whose return value is undefined when it creates a side effect
  • Document the ownership boundary in code review: any effect that creates a timer, subscription, or request must be reviewed alongside its cleanup function before merge

Safe commands and checks

node -e "console.log(require('react').version)" # Print the installed React version so the effect lifecycle behavior is matched to the runtime under test
node --inspect-brk=0 -e "process.title='react-effect-probe'" # Start a Node process with the inspector attached so a debugger can break inside an effect and confirm whether cleanup is wired
npx react-devtools # Launch React DevTools to inspect fibers and the hooks list of the suspect component, looking for effects without a returned cleanup
node -e "const a=new AbortController(); fetch('https://example.invalid/api',{signal:a.signal}).catch(()=>{}); a.abort()" # Demonstrate that an AbortController created in an effect must be aborted in cleanup for late responses to be cancelled
node --heap-prof -e "setInterval(()=>{},1e3)" # Capture a heap profile that shows the interval retaining its callback closure, illustrating why cleanup must hold the same handle
node -e "process.on('warning',(w)=>console.error('NODE_WARNING',w.name,w.message))" # Surface Node-level warnings so cleanup misses that escalate to unhandled rejections are not silently dropped
npx --yes source-map-explorer build/static/js/main.<hash>.js # Inspect the production bundle to confirm cleanup code paths are present and not tree-shaken away in the shipped build