If you have ever had a bug you could not explain, infinite loops, stale data, or memory leaks, useEffect was probably involved.
Introduction
useEffect is one of the most used hooks in React. It is also one of the most misunderstood. Whether you are a solo developer leveling up your skills or a tech lead looking to Hire React.js Developers who truly understand the framework, knowing how useEffect works under the hood is non-negotiable in 2026. Even experienced developers ship bugs caused by subtle useEffect mistakes every single day. In this post, we will walk through the 5 most common useEffect mistakes, show you exactly why they break your app, and give you the correct pattern to fix each one. By the end, you will have a much clearer mental model of how useEffect actually works.
Mistake 1 - Missing the Cleanup Function
The Problem
You set up a subscription, a timer, or an event listener inside useEffect but never clean it up. This causes memory leaks and bugs that are incredibly hard to trace.
// WRONG
useEffect(() => {
const interval = setInterval(() => {
console.log("Tick");
}, 1000);
}, []);
This interval keeps running even after the component unmounts. In large apps, these leaks stack up and slow everything down.
The Fix
Always return a cleanup function that tears down whatever you set up.
// CORRECT
useEffect(() => {
const interval = setInterval(() => {
console.log("Tick");
}, 1000);
return () => {
clearInterval(interval); // Cleanup on unmount
};
}, []);
The same pattern applies to event listeners, WebSocket connections, and API subscriptions.
// CORRECT - Event listener cleanup
useEffect(() => {
const handleResize = () => console.log(window.innerWidth);
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
Rule of thumb: If you set it up inside useEffect, clean it up in the return function.
Mistake 2 - Stale Closures in the Dependency Array
The Problem
You reference a variable inside useEffect but forget to add it to the dependency array. React captures the value at the time the effect runs, so you end up reading an old, stale value.
// WRONG
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
console.log(count); // Always prints 0 - stale closure!
setCount(count + 1); // Always sets to 1, never increments correctly
}, 1000);
return () => clearInterval(timer);
}, []); // count is missing from deps
count is frozen at 0 inside the closure because it is never listed as a dependency.
The Fix
Option A - Add the dependency properly:
// CORRECT - Option A
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(timer);
}, [count]);
Option B - Use the functional updater form of setState (best for counters and toggling):
// CORRECT - Option B (preferred for counters)
useEffect(() => {
const timer = setInterval(() => {
setCount(prev => prev + 1); // No dependency needed
}, 1000);
return () => clearInterval(timer);
}, []);
Option B is cleaner because the effect does not need to re-run every time count changes.
Mistake 3 - Using an Object or Array as a Dependency
The Problem
Objects and arrays are compared by reference in JavaScript, not by value. If you create an object or array inside the component and pass it as a dependency, React sees a new reference on every render and runs the effect endlessly.
// WRONG
const filters = { category: "electronics", inStock: true };
useEffect(() => {
fetchProducts(filters);
}, [filters]); // New object reference every render = infinite loop
This triggers the effect on every single render, even when the values inside the object have not changed.
The Fix
Option A - Use primitive values as dependencies instead of objects:
// CORRECT - Option A
const category = "electronics";
const inStock = true;
useEffect(() => {
fetchProducts({ category, inStock });
}, [category, inStock]); // Primitives compared by value
Option B - Memoize the object with useMemo:
// CORRECT - Option B
const filters = useMemo(() => ({
category: "electronics",
inStock: true
}), []); // Stable reference
useEffect(() => {
fetchProducts(filters);
}, [filters]);
Mistake 4 - Fetching Data Without Handling Race Conditions
The Problem
You fetch data inside useEffect but the component re-renders (or unmounts) before the request completes. An older request finishes after a newer one and overwrites your state with stale data.
// WRONG
useEffect(() => {
fetch(`/api/user/${userId}`)
.then(res => res.json())
.then(data => setUser(data)); // Could be stale data from an old request
}, [userId]);
If the user clicks quickly and userId changes twice, the first slow request might resolve after the second one, setting the wrong user in state.
The Fix
Use an isCancelled flag or the AbortController API to ignore outdated responses.
// CORRECT - Using AbortController
useEffect(() => {
const controller = new AbortController();
fetch(`/api/user/${userId}`, { signal: controller.signal })
.then(res => res.json())
.then(data => setUser(data))
.catch(err => {
if (err.name === "AbortError") return; // Ignore cancelled requests
console.error(err);
});
return () => {
controller.abort(); // Cancel the request on cleanup
};
}, [userId]);
// CORRECT - Using isCancelled flag
useEffect(() => {
let isCancelled = false;
fetch(`/api/user/${userId}`)
.then(res => res.json())
.then(data => {
if (!isCancelled) setUser(data); // Only update if still relevant
});
return () => {
isCancelled = true;
};
}, [userId]);
The AbortController approach is preferred in 2026 because it also cancels the network request itself, saving bandwidth.
Mistake 5 - Putting Too Much Logic Inside useEffect
The Problem
Developers often dump complex logic directly into useEffect, making it hard to read, test, and reuse.
// WRONG - useEffect doing too much
useEffect(() => {
if (user && user.isLoggedIn) {
fetch(`/api/dashboard/${user.id}`)
.then(res => res.json())
.then(data => {
setDashboard(data);
localStorage.setItem("lastVisit", Date.now());
analytics.track("dashboard_viewed", { userId: user.id });
});
}
}, [user]);
This is three separate concerns crammed into one effect. It is untestable and will cause confusing bugs when any one piece changes.
The Fix
Split each concern into its own useEffect or extract logic into a custom hook.
// CORRECT - Separate effects for separate concerns
useEffect(() => {
if (!user?.isLoggedIn) return;
fetchDashboard(user.id).then(setDashboard);
}, [user]);
useEffect(() => {
if (!user?.isLoggedIn) return;
localStorage.setItem("lastVisit", Date.now());
}, [user]);
useEffect(() => {
if (!user?.isLoggedIn) return;
analytics.track("dashboard_viewed", { userId: user.id });
}, [user]);
Even better, extract it into a custom hook:
// CORRECT - Custom hook
function useDashboard(user) {
const [dashboard, setDashboard] = useState(null);
useEffect(() => {
if (!user?.isLoggedIn) return;
const controller = new AbortController();
fetch(`/api/dashboard/${user.id}`, { signal: controller.signal })
.then(res => res.json())
.then(setDashboard)
.catch(err => {
if (err.name !== "AbortError") console.error(err);
});
return () => controller.abort();
}, [user]);
return dashboard;
}
// Usage in component
const dashboard = useDashboard(user);
Custom hooks make your effects reusable, testable, and far easier to read.
Quick Reference - All 5 Fixes at a Glance
| Mistake | Root Cause | Fix |
|---|---|---|
| Missing cleanup | Resource leak on unmount | Return a cleanup function |
| Stale closure | Missing dependency | Add to deps or use functional updater |
| Object as dependency | Reference comparison | Use primitives or useMemo |
| Race conditions | Async + re-render timing | Use AbortController |
| Too much in one effect | Poor separation of concerns | Split effects or use custom hooks |
Bonus - The Two Questions to Ask Before Every useEffect
Before you write any useEffect, ask yourself:
1. Does this actually need to be an effect?
A lot of things developers put in useEffect do not belong there. Derived state, event handlers, and one-time initializations often do not need useEffect at all. If you are transforming data from props or state, just compute it inline or use useMemo.
2. What cleans this up?
If you cannot answer this question, you probably have a memory leak waiting to happen. Always think cleanup first.
Conclusion
useEffect is not complicated once you understand the mental model. Every mistake above comes down to the same root cause: not thinking carefully about when the effect runs, what it depends on, and what it leaves behind.
Fix these five patterns in your codebase and you will eliminate a whole category of React bugs overnight.
Which of these mistakes have you made before? Drop a comment below. I can guarantee everyone reading has hit at least three of them. And if this saved you some debugging time, a ❤️ helps other devs find this post.
Top comments (0)