React components often interact with resources that exist outside React’s rendering system. Examples include timers, event listeners, subscriptions, network requests, and browser APIs. If those resources are not managed correctly, an application can perform unnecessary work, display stale results, or retain resources longer than necessary.
Understand Effect Cleanup
An Effect is useful when a component must synchronize with an external system. When that synchronization is no longer needed, its cleanup function should release the associated resource.
import { useEffect, useState } from "react";
function WindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
function updateWidth() {
setWidth(window.innerWidth);
}
window.addEventListener("resize", updateWidth);
return () => {
window.removeEventListener("resize", updateWidth);
};
}, []);
return <p>Window width: {width}px</p>;
}
The cleanup removes the event listener when the component unmounts. React also runs cleanup before rerunning an Effect whose dependencies have changed.
Clean Up Timers and Subscriptions
A timer that continues running after a component no longer needs it wastes resources and may trigger unwanted state updates.
useEffect(() => {
const timerId = setInterval(() => {
console.log("Checking status");
}, 5000);
return () => {
clearInterval(timerId);
};
}, []);
The same principle applies to WebSocket connections, observers, third-party subscriptions, and other resources with explicit disposal or unsubscribe methods.
A useful rule is to create a resource and define its cleanup in the same Effect.
Handle Requests That Become Irrelevant
Consider a search component that requests data whenever the query changes. If an earlier request finishes after a newer request, its response could overwrite the newer results.
An AbortController can cancel fetch requests when the query changes or the component unmounts.
useEffect(() => {
const controller = new AbortController();
async function loadResults() {
try {
const response = await fetch(
`/api/search?q=${encodeURIComponent(query)}`,
{ signal: controller.signal }
);
if (!response.ok) {
throw new Error("Search request failed");
}
const data = await response.json();
setResults(data);
} catch (error) {
if (error.name !== "AbortError") {
setError(error);
}
}
}
loadResults();
return () => controller.abort();
}, [query]);
This example assumes query, setResults, and setError are defined by the component. Aborting requests reduces unnecessary work, although cancellation does not guarantee that a server has stopped processing a request already received.
Avoid Incorrect Dependency Lists
Effects must declare the reactive values they use. Omitting dependencies can cause stale closures, while unnecessary dependencies can cause repeated setup and cleanup.
Do not suppress dependency warnings simply to force an Effect to run once. Instead, restructure the code so the Effect reflects the actual synchronization required.
Test Cleanup Behavior
Useful tests include:
- Mounting and unmounting a component with an active timer.
- Changing a query while an earlier request is pending.
- Verifying that event listeners are removed.
- Checking behavior under React Strict Mode in development.
Strict Mode may intentionally repeat certain setup and cleanup sequences to expose bugs. Properly designed Effects should tolerate this behavior.
Conclusion
Memory and resource problems in React frequently begin with incomplete cleanup rather than with rendering itself. Pair resource creation with cleanup, manage asynchronous work carefully, and use accurate Effect dependencies to keep components predictable.