React useEffect Cleanup Patterns for Timers, Listeners, and Fetch Races

SitePoint TeamPublished inJavaScript·Programming·Web·
October 19, 2026
The AI briefing for Developers
Stay up to date with AI tools, model releases, and developer workflows that matter.
Weekly. Free. One click to leave.
SitePoint Premium
Stay Relevant and Grow Your Career in Tech
- Premium Results
- Publish articles on SitePoint
- Daily curated jobs
- Learning Paths
- Discounts to dev tools
7 Day Free Trial. Cancel Anytime.
Code examples require React 18+. StrictMode double-invocation behavior was introduced in React 18.
Missed cleanup functions in React useEffect are among the most common sources of memory leaks, stale-state bugs, and race conditions. Typical beginner tutorials demonstrate the happy path: fetch data, set state, render. They rarely address what happens when a component unmounts mid-flight, when a dependency changes faster than an async operation resolves, or when event listeners silently pile up across re-renders. This article provides a unified mental model for useEffect cleanup across three categories (timers, listeners, and fetch races), a reference table mapping setup to cleanup to pitfall, and a copy-paste PR review checklist that development teams can adopt immediately.
Table of Contents
How useEffect Cleanup Actually Works
The Setup-Cleanup Lifecycle in One Sentence
The mental model is simple: React runs the setup function when the component mounts, runs the cleanup function when the component unmounts, and on every dependency change, runs cleanup for the previous render’s effect before running setup for the current render’s effect. This cleanup-then-setup cycle on dependency change is the part most developers either overlook or misunderstand. It means that every effect invocation is paired with a corresponding teardown, and React guarantees that ordering. The setup function acquires resources; the cleanup return releases them. The dependency array declares which values, when changed, trigger that cycle again.
Why StrictMode Double-Invokes Your Effects
React 18’s StrictMode intentionally runs each component’s effect twice in development by simulating an unmount and remount: setup → cleanup → setup. Your setup function executes twice on the initial render in development. This double invocation exists specifically to surface missing cleanup logic. If a component registers an event listener in setup but never removes it in cleanup, StrictMode will produce two listeners after the simulated remount, making the bug immediately visible during development. As documented in the React docs on StrictMode (https://react.dev/reference/react/StrictMode), this behavior only occurs in development mode and does not affect production builds. However, the bugs it exposes are entirely real. A component that behaves incorrectly under StrictMode’s double-mount has a latent cleanup bug that will manifest in production whenever the component remounts due to navigation, conditional rendering, or parent re-renders.
A component that behaves incorrectly under StrictMode’s double-mount has a latent cleanup bug that will manifest in production whenever the component remounts due to navigation, conditional rendering, or parent re-renders.
import{ useEffect }from'react';functionExampleComponent({ dependency }){useEffect(()=>{console.log('Effect setup with dependency:', dependency);return()=>{console.log('Effect cleanup for dependency:', dependency);};},[dependency]);return<div>Anatomy of useEffect</div>;}Pattern 1: Cleaning Up Timers (setInterval / setTimeout)
The Bug: Stacking Intervals on Re-render
When a setInterval starts inside a useEffect that depends on a prop or state value, every time that dependency changes, React re-runs the effect. Without cleanup, the previous interval continues running. A second interval starts. Then a third. The observable symptom depends on what the interval does: a counter increments N times per tick (where N is the number of orphaned intervals), a polling callback fires multiple times per tick, or a UI animation speeds up erratically. Each orphaned interval holds a closure over stale state, compounding the problem. Under StrictMode’s double-mount in development, this bug surfaces immediately as two intervals running from the very first render.
The Fix: clearInterval in the Cleanup Return
The correct pattern creates the interval in setup and clears it in cleanup. When the dependency changes, React clears the old interval before starting a new one with the updated value. This guarantees exactly one active interval at any point in time.
import{ useEffect, useState }from'react';functionPollingComponent({ delay }){const[count, setCount]=useState(0);useEffect(()=>{if(!delay || delay <=0||!Number.isFinite(delay))return;const id =setInterval(()=>{setCount(c=> c +1);}, delay);return()=>{clearInterval(id);};},[delay]);return<div>Count:{count}</div>;}The same principle applies to setTimeout. Replace setInterval with setTimeout and clearInterval with clearTimeout. Store the timer ID returned by the scheduling function, then clear it in the cleanup return.
Pattern 2: Cleaning Up Event Listeners
The Bug: Listener Pile-up on Window/Document
Adding an event listener to window, document, or any DOM node outside the component’s own render tree requires manual cleanup. Without a matching removeEventListener in the cleanup return, every re-render (or StrictMode double-mount) attaches an additional handler. The handlers accumulate. A resize listener that updates state will trigger setState multiple times per resize event, once for each orphaned listener. This degrades performance and can cause subtle state corruption when multiple handlers race to update the same state variable.
The Fix: Matching add and remove with the Same Reference
The critical detail is that removeEventListener must receive the exact same function reference passed to addEventListener. An inline arrow function created inside the effect body produces a new reference on every invocation, so calling removeEventListener with a different inline arrow does nothing. The listener is never removed. Declare the handler as a named function (or a variable holding the function) inside the effect, then pass that same variable to both addEventListener and removeEventListener.
The critical detail is that
removeEventListenermust receive the exact same function reference passed toaddEventListener.
import{ useEffect, useState }from'react';functionWindowSizeTracker(){const[width, setWidth]=useState(typeofwindow!=='undefined'?window.innerWidth:0);useEffect(()=>{if(typeofwindow==='undefined')return;functionhandleResize(){setWidth(window.innerWidth);}window.addEventListener('resize', handleResize);return()=>{window.removeEventListener('resize', handleResize);};},[]);return<div>Window width:{width}px</div>;}If the handler depends on a prop or state value and is defined outside the effect (for example, with useCallback), that handler must appear in the dependency array. When the reference changes, React cleans up the old listener and attaches the new one. Note that re-adding the listener on each dependency change is correct behavior: the old handler (with stale closure) is removed and the new handler (with fresh values) is attached.
Pattern 3: Cancelling Fetch Race Conditions with AbortController
The Bug: Stale Responses Overwriting Fresh State
Consider a search component where the user types quickly. Each keystroke triggers a new fetch. The request for “rea” might take 300ms while the request for “react” takes 50ms. The “react” response arrives first, and the UI shows the correct results. Then the “rea” response arrives, and because nothing prevented it from calling setState, it overwrites the UI with stale, incorrect results. This is a classic race condition. The slower response wins not because it is more recent, but because it resolves later. Without cancellation, there is no mechanism to tell the browser to discard an in-flight request whose results are no longer relevant.
The Fix: AbortController Signal in Cleanup
The modern solution is AbortController. Each effect invocation creates a new controller instance. The controller’s signal property is passed to fetch as an option. When the cleanup function runs (because the dependency changed or the component unmounted), it calls controller.abort(), which cancels the in-flight request. The fetch promise rejects with an AbortError. Browsers generally attempt to cancel the underlying network request as well, though the behavior varies by engine (see the Fetch spec on AbortSignal and MDN’s browser compatibility tables). This guarantees that only the most recent request can successfully set state.
Handling the AbortError Gracefully
Calling abort() rejects the promise. This is normal, not an error condition. The catch block must distinguish AbortError from genuine network failures. Without this guard, the UI would flash an error for every intentional cancellation, or the rejection would trigger error-logging infrastructure. The standard check inspects the error’s name property: if it equals 'AbortError', the catch block returns early without updating state or surfacing an error.
import{ useEffect, useState }from'react';functionSearchResults({ query }){const[results, setResults]=useState([]);const[error, setError]=useState(null);const[loading, setLoading]=useState(false);useEffect(()=>{if(!query){setResults([]);setError(null);setLoading(false);return;}const controller =newAbortController();asyncfunctionfetchResults(){setLoading(true);setError(null);try{const response =awaitfetch(`/api/search?q=${encodeURIComponent(query)}`,{signal: controller.signal,});if(!response.ok){thrownewError(`HTTP${response.status}`);}const data =await response.json();const items =Array.isArray(data?.items)? data.items:[];setResults(items);}catch(err){if(err.name==='AbortError'){return;}setError(err.message);}finally{setLoading(false);}}fetchResults();return()=>{controller.abort();};},[query]);if(error)return<div>Error:{error}</div>;if(loading)return<div>Loading...</div>;return(<ul>{results.map((item, index)=>(<likey={item.id??`item-${index}`}>{item.title??'(Untitled)'}</li>))}</ul>);}Note that the effect function itself is not async. An inner async function is declared and called immediately. This matters because useEffect expects its return value to be either undefined or a cleanup function. React ignores a returned promise and never registers a cleanup function from it.
Reference Table: Setup, Cleanup, and Pitfalls at a Glance
The following table captures the universal pattern across the most common side effects in React applications. Each row shows the same structure: acquire a re failure mode when cleanup is missing
| Scenario | Setup Action | Cleanup Action | Common Pitfall |
|---|---|---|---|
setInterval / setTimeout | Start timer | clearInterval / clearTimeout | Stacking intervals on dependency change |
addEventListener | Attach handler | removeEventListener with same ref | Inline arrow ≠ same reference |
fetch | Fire request with signal | controller.abort() | Stale response overwrites state |
| WebSocket | new WebSocket(url) + attach handlers | Null handlers, then ws.close() | Connection leak on unmount |
Third-party subscription (e.g., Firebase onSnapshot) | Subscribe | Call unsubscribe function | Forgetting the return value is the unsub |
WebSocket and Firebase patterns appear here for reference; see MDN’s WebSocket API docs and Firebase documentation for full implementation details.
The pattern is always the same: acquire recreates something, cleanup destroys it. If setup connects, cleanup disconnects. Memorize this symmetry and every new side effect becomes a direct application of the same principle
PR Review Checklist for useEffect Cleanup
This checklist is designed to be dropped directly into a team’s contributing guidelines or used as a mental walkthrough during code review. Every question targets a specific category of cleanup bug.
- Does every
useEffectthat creates a side effect return a cleanup function? - Does the cleanup fully reverse the setup (clear timer, remove listener, abort fetch)?
- Are event handler references stable (named function or
useCallback) soremoveEventListeneractually matches? - Is
AbortErrorcaught and handled separately from genuine network errors (e.g., returning early without setting error state or logging)? - Does the dependency array include every value referenced inside the effect?
- Has the component been tested under React StrictMode double-mount?
- Are there any
eslint-disable react-hooks/exhaustive-depscomments? If so, is each justified with an accompanying code comment explaining why the suppression is safe?
A PR that passes all seven checks covers the most frequently reported categories of cleanup-related bugs before they reach production.
Common Mistakes Even Intermediate Developers Make
Suppressing the Exhaustive-Deps Lint Rule
The react-hooks/exhaustive-deps ESLint rule exists to catch missing dependencies that cause stale closures. Disabling the rule hides the bug instead of fixing it. The effect closes over a stale value and never re-runs in response to that value changing, producing a stale closure that is difficult to reproduce and debug. When the rule flags a dependency, add it. If that causes unwanted re-runs, restructure the effect: extract logic, split into multiple effects, or stabilize references with useCallback or useMemo. Suppression should be a last resort, always paired with a comment explaining why the omission is intentional and safe.
Returning a Promise Instead of a Function
Marking the effect callback as async is a common trap. An async function implicitly returns a promise. React expects the return value to be either undefined or a synchronous cleanup function. React ignores a returned promise and never registers a cleanup function, so nothing runs on unmount or dependency change. The fix is always the same: define an inner async function inside the effect and invoke it immediately, keeping the outer effect function synchronous so that the return statement provides a proper cleanup function.
React ignores a returned promise and never registers a cleanup function, so nothing runs on unmount or dependency change.
Key Takeaways
- Every
useEffectthat acquires a resource must return a cleanup function that releases it. The only exception: an effect that produces no persistent side effect (e.g., a one-time fire-and-forget analytics call). AbortControlleris the modern, browser-native tool for cancelling fetch requests. It eliminates race conditions without boolean flags or sequence counters.- Acquire resource, release resource, declare dependencies honestly. That principle applies to timers, listeners, fetches, WebSockets, and third-party subscriptions alike.
- Drop the seven-item PR review checklist into your contributing guidelines or pull request template. It catches the most commonly reported cleanup bugs before they reach production.
Sharing our passion for building incredible internet things.


