useEffectis one of the most used and most misunderstood hooks in React. The hook itself is not complicated, but the rules around when it runs, what it captures, and what belongs in the dependency array are subtle enough that they produce consistent categories of bugs: infinite loops, stale values, missing cleanup, and effects running when they should not.This guide covers the practical patterns that handle the most common use cases correctly, explains the Strict Mode double-invocation behavior, identifies when
useEffectis the wrong tool, and shows how custom hooks reduce complexity when effect logic is reused.What this covers:
Running code once on mount
Re-fetching data when dependencies change
Cleanup functions for subscriptions, timers, and event listeners
Avoiding stale closures
When to skip
useEffectentirelyDebouncing with
setTimeoutThe ESLint exhaustive-deps rule
Extracting effects into custom hooks
1. Run Once on Mount
The empty dependency array tells React to run the effect only after the first render:
useEffect(() => {
console.log("Component mounted");
}, []);
Suitable for: initializing a third-party library, firing an analytics event, subscribing to an external store on mount.
Strict Mode double-invocation: In development, React Strict Mode intentionally mounts, unmounts, and remounts every component to surface effects that do not clean up correctly. This causes the effect to run twice in development. In production it runs once. If an effect runs twice and produces unexpected behavior, it is usually because the effect is not idempotent and is missing a cleanup function. The double invocation is surfacing a real problem, not causing one.
2. Re-fetch Data When a Dependency Changes
useEffect(() => {
fetchUserData(userId);
}, [userId]);
The effect runs after the first render and again whenever userId changes. This is the standard pattern for loading data in response to a prop or state value.
One problem with data fetching in useEffect is that it does not handle race conditions: if userId changes quickly, two requests may be in flight simultaneously, and the earlier response may arrive after the later one. The fix is an AbortController in the cleanup function:
useEffect(() => {
const controller = new AbortController();
fetchUserData(userId, { signal: controller.signal })
.then(setUser)
.catch((err) => {
if (err.name !== "AbortError") {
setError(err);
}
});
return () => controller.abort();
}, [userId]);
The cleanup cancels the in-flight request when userId changes again or when the component unmounts.
For new projects, data fetching libraries like TanStack Query or SWR handle caching, deduplication, and race conditions automatically. Using useEffect for data fetching directly is workable but requires this kind of careful handling.
3. Cleanup Functions
The function returned from a useEffect callback runs before the component unmounts and before the effect runs again on the next render cycle. It is used to tear down anything the effect set up.
useEffect(() => {
const id = setInterval(() => {
console.log("tick");
}, 1000);
return () => clearInterval(id);
}, []);
Without the cleanup, the interval continues running after the component unmounts, which causes a memory leak and potentially a state update on an unmounted component.
The same pattern applies to:
addEventListener/removeEventListenerWebSocket connections
Observable subscriptions
Any external resource that must be explicitly released
A useful mental model: every resource the effect acquires should be released in the cleanup function.
4. Avoiding Stale Closures
A stale closure occurs when an effect captures a value from an earlier render and that value has since changed, but the effect still holds the old version.
// Stale: count may be outdated when handleScroll runs
useEffect(() => {
function handleScroll() {
console.log(count); // captures count from the render when this ran
}
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []); // missing count in dependencies
The correct fix is to include count in the dependency array:
useEffect(() => {
function handleScroll() {
console.log(count);
}
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, [count]); // re-registers the listener when count changes
For cases where re-registering the listener on every count change is expensive, a useRef can hold the latest value without triggering re-registration:
const countRef = useRef(count);
useEffect(() => {
countRef.current = count;
}, [count]);
useEffect(() => {
function handleScroll() {
console.log(countRef.current); // always current
}
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []); // listener registered once
The rule of thumb is: include everything the effect uses in the dependency array, unless there is a specific reason not to and the alternative is explicitly handled.
5. When to Skip useEffect
React's documentation now explicitly identifies several patterns where useEffect is the wrong tool:
Derived state. If a value can be computed directly from existing state or props, compute it inline rather than syncing it with an effect:
// Unnecessary — an effect to derive a value
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
// Correct — derive inline
const fullName = `${firstName} ${lastName}`;
Syncing props to state. Copying a prop into state with useEffect creates synchronization problems. If a value comes from a prop and needs to be controlled, either lift the state up or use the prop directly.
Responding to a user event. If code should run because a user clicked a button or submitted a form, put it in the event handler, not in an effect that watches for the resulting state change.
Transforming data for rendering. Any data transformation that can be done during render should be done there. An effect that transforms and stores in state adds an extra render cycle for no benefit.
The guiding question is: is this code running because of a user interaction or a render, or is it synchronizing with something outside of React? If the former, it probably belongs in an event handler. useEffect is for the latter.
6. Debounce with setTimeout
useEffect(() => {
const handler = setTimeout(() => {
performSearch(searchTerm);
}, 500);
return () => clearTimeout(handler);
}, [searchTerm]);
Every time searchTerm changes, the previous timeout is canceled and a new one is started. The search only fires if searchTerm has not changed for 500 milliseconds. This prevents a request on every keystroke without needing an external debounce library.
7. The ESLint Exhaustive-Deps Rule
The eslint-plugin-react-hooks package includes an exhaustive-deps rule that warns when a dependency is missing from a useEffect dependency array:
npm install --save-dev eslint-plugin-react-hooks
In the ESLint configuration:
{
"plugins": ["react-hooks"],
"rules": {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
}
Missing dependencies are one of the primary causes of stale closure bugs. The linter catches the cases that are easy to miss during code review. Disabling the rule with an inline comment should be a deliberate decision with a documented reason, not a way to silence a warning without understanding it.
8. Extract Repeated Effect Logic into Custom Hooks
When the same useEffect pattern appears in multiple components, extracting it into a custom hook reduces repetition and makes the component code easier to read.
function useWindowSize() {
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
function updateSize() {
setSize({
width: window.innerWidth,
height: window.innerHeight,
});
}
window.addEventListener("resize", updateSize);
updateSize();
return () => window.removeEventListener("resize", updateSize);
}, []);
return size;
}
Usage in a component:
const { width, height } = useWindowSize();
The component no longer contains any effect logic. The hook handles setup, state, and cleanup internally. This pattern also makes the effect easier to test in isolation.
Key Takeaways
useEffectruns after render. Use it for synchronizing with external systems, not for logic that belongs in event handlers or derivations that can be computed inline.The Strict Mode double-invocation is intentional. If an effect behaves incorrectly when run twice, the cleanup function is missing or incomplete.
Every resource acquired in an effect (timers, listeners, connections) should be released in the cleanup function returned from the effect.
Stale closures result from missing dependencies. Include everything the effect uses in the dependency array, or use
useRefdeliberately when re-running the effect on every change is too expensive.Derived state, event responses, and data transformations for rendering do not belong in
useEffect.The
exhaustive-depsESLint rule catches missing dependencies automatically. Treat its warnings seriously.Extract repeated effect logic into custom hooks to reduce component complexity and make effects easier to test.
Conclusion
Most useEffect bugs fall into a small number of categories: missing cleanup, missing dependencies, or using the hook for something that belongs elsewhere. Understanding these categories makes the bugs easier to spot and prevents them from being introduced in the first place.
The patterns in this guide cover the majority of real-world use cases. When the patterns feel like they are working against the code rather than helping it, it is usually a signal that useEffect is not the right tool for that particular problem.
Hit a specific useEffect bug that took a while to diagnose? Share what it was and what fixed it in the comments.




