Why Your React useEffect Cleanup Function Isn't Running (The Dependency Array Gotcha)
You add a cleanup function to your useEffect. You expect it to run when the component unmounts, or when a dependency changes before the effect re-runs. You test it. Nothing happens. No console log, no unsubscribe, no cleared interval — just silence, and a bug report from a user seeing duplicate event listeners or a memory leak that grows worse the longer they use your app.
If you've hit this, you're not misunderstanding React's cleanup model in some obvious way. You've almost certainly run into one of a handful of specific dependency array mistakes that are easy to make and genuinely confusing to debug, because the effect looks correct at a glance. Let's go through exactly why this happens and how to actually fix it.
A Quick Refresher on How Cleanup Is Supposed to Work
Before diagnosing the bug, it's worth being precise about how React's useEffect cleanup is designed to function. The cleanup function you return from a useEffect hook is executed under specific conditions:
- Before the component unmounts: This is the most common expectation. When your component is removed from the DOM, React ensures any lingering side effects are cleaned up to prevent memory leaks.
- Before the effect re-runs: If the dependencies of your
useEffectchange, React first runs the cleanup function from the *previous* effect, and then it runs the *new* effect. This prevents race conditions and ensures that only one set of side effects is active at a time.
Think of it like tidying up your workspace before starting a new project. You put away the tools from the last job (cleanup) before you unpack and set up for the new one (effect). If you skip the tidying, you might end up with tools from both projects cluttering your desk, or worse, trying to use an old tool on the new project.
The key to this process is the dependency array. This array tells React which values your effect depends on. When any value in this array changes between renders, React knows it needs to re-run the effect, and critically, trigger the cleanup from the prior run.
The Most Common Culprit: An Empty Dependency Array
The simplest and most frequent mistake is providing an empty dependency array ([]) when your effect actually depends on values that change over time. An empty array tells React, "This effect only needs to run once, after the initial render, and never again." Consequently, the cleanup function will only run when the component unmounts. It will never run before a re-render, because React assumes nothing has changed that would necessitate re-running the effect.
Consider this scenario:
function MyComponent() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log('Effect ran with count:', count);
const intervalId = setInterval(() => {
setCount(prevCount => prevCount + 1);
}, 1000);
// Cleanup function
return () => {
console.log('Cleaning up interval:', intervalId);
clearInterval(intervalId);
};
}, []); // <-- PROBLEM: Empty dependency array
return (
Count: {count}
);
}
In this code, the effect sets up an interval that increments a counter. The cleanup function is intended to clear this interval. However, because the dependency array is empty ([]), React believes the effect is static and should only run once. The cleanup function will only execute when MyComponent unmounts. The interval will continue to run indefinitely, even if the component re-renders due to other state changes elsewhere. If the component were to be unmounted and then re-mounted, you'd have a new interval running without the old one being cleared, leading to duplicate intervals and potentially a crash.
The fix here is straightforward: include the dependencies that the effect uses. In this case, the effect doesn't directly *use* count in its logic (it uses the updater function form of setCount), but it implicitly relates to the component's state. A more accurate dependency for many such cases would be to include any props or state variables that *influence* the effect's behavior or that the effect *observes*. If the effect *did* use `count` directly, like setCount(count + 1), then `count` would absolutely need to be in the dependency array.
For this specific interval example, if the goal is for the interval to run only once and be cleared on unmount, the empty array is correct. The problem arises if the interval's behavior is meant to adapt to changes or if you expect cleanup on re-renders where dependencies change.
Missing Dependencies: The Subtle Trap
This is where things get truly insidious. Your dependency array might not be empty, but it's incomplete. You've included some dependencies, but you've forgotten one or more. React's linter (eslint-plugin-react-hooks) is usually excellent at catching these, but it's not infallible, and sometimes developers disable rules or work in environments where the linter isn't configured correctly.
Consider a component that fetches data based on a prop:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
console.log(`Fetching user data for ID: ${userId}`);
const fetchUser = async () => {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
setUser(data);
};
fetchUser();
// Cleanup: Abort fetch if component unmounts or userId changes before fetch completes
// (This is a simplified example; actual abort logic can be more complex)
return () => {
console.log('Cleaning up fetch for user ID:', userId);
// AbortController logic would go here
};
}, [userId]); // <-- Correct: userId is included
// ... render user data
}
In this example, userId is correctly included in the dependency array. If userId changes, React will run the cleanup function (e.g., abort the previous fetch) before running the new effect to fetch data for the new userId. This is the intended behavior.
Now, imagine a variation:
function UserProfile({ userId, apiBaseUrl }) { // apiBaseUrl is a prop
const [user, setUser] = useState(null);
useEffect(() => {
console.log(`Fetching user data from ${apiBaseUrl} for ID: ${userId}`);
const fetchUser = async () => {
const response = await fetch(`${apiBaseUrl}/users/${userId}`);
const data = await response.json();
setUser(data);
};
fetchUser();
return () => {
console.log('Cleaning up fetch for user ID:', userId);
// AbortController logic
};
}, [userId]); // <-- PROBLEM: apiBaseUrl is missing!
// ... render user data
}
Here, the effect uses both userId and apiBaseUrl. If apiBaseUrl changes but userId remains the same, React sees no change in the dependency array and does not re-run the effect. The cleanup function also does not run. The effect continues to use the old apiBaseUrl. If the API endpoint has changed significantly, this could lead to incorrect data being fetched or failed requests, all without a clear indication from React that something is wrong.
The fix is to include all variables from the component scope (props, state, context, other variables declared in the component body) that are used inside the effect. If the effect uses a function defined within the component, that function should also be included (or memoized with useCallback if necessary).
Functions as Dependencies: A Special Case
Functions defined within your component body are also subject to dependency array rules. If your effect calls a function that was defined in the same component scope, and that function is not memoized with useCallback, it will be re-created on every render. If you then include this non-memoized function in your dependency array, your effect will run on every render, which is usually not the desired behavior.
Conversely, if you *don't* include a non-memoized function in the dependency array, but the function relies on props or state that *do* change, you can run into stale closure issues. The effect will keep a reference to the function as it existed during the initial render, potentially missing updates.
The solution is to use useCallback to memoize functions passed into or used by useEffect, and then include the memoized function in the dependency array. This ensures that the function reference only changes when its own dependencies change, allowing React to correctly manage effect re-runs and cleanups.
function DataFetcher({ url }) {
const [data, setData] = useState(null);
// Memoize the fetch function
const fetchData = useCallback(async () => {
console.log('Fetching data from:', url);
const response = await fetch(url);
const result = await response.json();
setData(result);
}, [url]); // Dependency for useCallback
useEffect(() => {
fetchData();
return () => {
console.log('Cleaning up fetch for URL:', url);
// AbortController logic if applicable
};
}, [fetchData, url]); // Dependencies for useEffect
// ... render data
}
In this pattern, fetchData is stable as long as url doesn't change. The useEffect then correctly depends on fetchData (and `url` for the cleanup), ensuring the effect re-runs only when the URL changes, and the cleanup runs appropriately before each re-run or on unmount.
Stale Closures Without Explicit Dependencies
Even if you don't have explicit functions or variables you've forgotten, you can still encounter stale closures. This often happens with asynchronous operations or when an effect relies on state that is updated via the functional update form.
Consider this:
function CounterDisplay() {
const [count, setCount] = useState(0);
useEffect(() => {
const intervalId = setInterval(() => {
// This uses the functional update form of setCount
// which is stable and doesn't need count in its deps.
setCount(prevCount => prevCount + 1);
}, 1000);
return () => {
clearInterval(intervalId);
// If you logged 'count' here, it would be stale!
console.log('Cleanup interval. Count was:', count); // PROBLEM: count is stale
};
}, []); // Empty dependency array
return Count: {count};
}
Here, the setCount(prevCount => prevCount + 1) is safe because it uses the updater function form, which doesn't depend on the current value of count. However, the cleanup function () => { ... console.log('Count was:', count); } is defined within the scope of the effect. Because the dependency array is empty, this effect (and its cleanup function) is only created once. If the component re-renders for any reason (even unrelated state changes), the count variable captured by the cleanup function's closure remains the value from the initial render. The cleanup function will log this stale value, not the actual current count.
The problem is that the cleanup function itself is part of the effect's closure. If the effect is only intended to run once (empty dependency array), then any variables captured by the effect's closure will also be stale on subsequent renders. If the cleanup function needs access to the latest state or props, the effect itself must be re-run to capture the new closure.
To fix this, you must include any state or props that the cleanup function (or the effect itself) directly uses in the dependency array. If the cleanup function only needs to clear an interval and doesn't need to read any state, then an empty dependency array might be fine, but you'd never log the stale value.
The Takeaway: Trust the Linter, Understand the Rules
The React team provides the eslint-plugin-react-hooks plugin precisely to help catch these dependency array gotchas. Always ensure it's configured in your project and pay attention to its warnings.
At its core, the rule is simple: If your effect reads a variable (prop, state, context, function) from the component scope, that variable must be in the dependency array. If you don't include it, React assumes the variable's value hasn't changed, and it won't re-run the effect or its cleanup. This leads to stale data, missed cleanups, and bugs that are difficult to trace because the code *looks* right.
Treating useEffect's dependency array as a strict contract between your effect and the values it relies on is the key to unlocking its predictable behavior and avoiding these common, frustrating bugs.
