useEffect is the most misused hook in React. Most of the time the bug isn't obvious: the component looks fine, until your API gets hammered or the wrong user's data shows up on screen.
I took four common mistakes, ran each one in real React 19 (rendered with react-dom in a simulated browser), and counted what actually happens. Here are the results and the fix for each.
Prefer video? Here's the 3-minute version:
Mistake 1: an object in the dependency array → infinite loop
function UserList() {
const [users, setUsers] = useState([]);
const filters = { active: true }; // new object every render!
useEffect(() => {
fetchUsers(filters).then(setUsers);
}, [filters]);
return <p>{users.length} users</p>;
}
React compares dependencies by reference. Every render creates a brand-new filters object, so React thinks it changed. The effect runs, calls setUsers, which re-renders, which creates a new object, which runs the effect again. Forever.
I counted the calls with a mocked fetchUsers:
fetchUsers called 15002 times in 1 second ❌
fetchUsers called 1 times in 1 second ✅ (fixed)
With a real API, that's your server getting hammered.
Fix: keep fresh objects and arrays out of the dependency array.
useEffect(() => {
fetchUsers({ active: true }).then(setUsers);
}, []);
Other options: depend on primitive values ([active] instead of [filters]), or wrap the object in useMemo.
Mistake 2: no cleanup → leaks (and a double timer)
useEffect(() => {
setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
}, []);
Nothing stops this interval. When the component unmounts, it keeps running. Here's what I measured inside <StrictMode>:
# without cleanup
after 5 seconds: 10s ❌ (two timers!)
3 s after unmount: 6 ticks still running ❌
# with cleanup
after 5 seconds: 5s ✅
3 s after unmount: 0 ticks ✅
Why did it count double? In development, Strict Mode intentionally mounts, unmounts and re-mounts your effects to expose missing cleanup. Without cleanup you end up with two intervals.
Fix: return a cleanup function. React calls it before the effect re-runs and when the component unmounts.
useEffect(() => {
const id = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
return () => clearInterval(id);
}, []);
The same applies to event listeners, subscriptions, WebSockets and observers.
Mistake 3: a fetch race condition → wrong data on screen
function Profile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]);
return <p>user {userId}: {user?.name}</p>;
}
In my test, user 1's request takes 300 ms and user 2's takes 100 ms. Click user 1, then quickly user 2. Request 2 finishes first, then request 1 finishes and overwrites it:
user 2: Ada ❌ (Ada is user 1!)
Fix: ignore stale responses with a flag in the cleanup.
useEffect(() => {
let ignore = false;
fetchUser(userId).then(u => {
if (!ignore) setUser(u);
});
return () => { ignore = true; };
}, [userId]);
user 2: Linus ✅
When userId changes, the old effect's cleanup sets ignore = true, so its late response is thrown away. You can also cancel the request with an AbortController, or use a data library like TanStack Query, which handles this for you.
Mistake 4: useEffect for derived values → an extra render
const [name, setName] = useState("");
useEffect(() => {
setName(first + " " + last);
}, [first, last]);
This renders twice: first with an empty name, then again with the real one. I logged each render:
renders: 2 → "", "Ada Lovelace" ❌
renders: 1 → "Ada Lovelace" ✅
That's a wasted render and a flash of wrong content.
Fix: if you can calculate it from props or state, just calculate it during render.
const name = first + " " + last;
For expensive calculations, use useMemo.
Checklist
- No new objects or arrays in the dependency array (or
useMemothem) - Started something? Return a cleanup
- Fetching? Ignore stale responses (or use
AbortController) - Can you calculate it during render? Then you don't need an effect
- Strict Mode runs effects twice in development on purpose, to find these bugs
Which of these have you shipped? 😅 Tell me in the comments.
I post one tested coding tip every day on YouTube at Refactorly. The 30-second version of this one is here.
Top comments (0)