React useEffect Common Mistakes
Table of Contents + −
In the last lesson you learned about React Fetching Data with useEffect. Now this lesson is a practical checklist of the common useEffect bugs, with the wrong way and the quick fix for each.
🔁 Mistake 1: the infinite loop
This is the scary one. Your effect sets state, and that state change re-runs the effect, which sets state again, forever.
- The effect calls
setCount, which causes a re-render. - After the render React checks the dependency array. With no array, it runs the effect on every render.
- So it sets state, re-renders, runs again, sets state again. The loop never stops and the page freezes.
The trap is leaving the dependency array out while setting state inside. That’s an infinite loop.
Here’s the broken version and the fix side by side.
// ❌ Wrong - no dependency array, so it sets state and re-runs foreveruseEffect(() => { setCount(count + 1);});
// ✅ Right - run once with [], or compute the value during render insteaduseEffect(() => { setCount(1);}, []);So the fix is to give the effect the right dependencies.
- If something should happen only once, use an empty array.
- And honestly, if you’re just calculating a value from other state, you usually don’t need an effect at all. We’ll come back to that idea in the next lesson.
Setting state in an effect with no deps loops forever
If an effect sets state and has no dependency array, every render triggers the effect, which triggers another render. The browser tab will freeze. Always ask: does this effect really need to set state, and if so, what should it depend on?
📭 Mistake 2: missing dependencies
You already met this one in the deps lesson. You leave a value out of the array, and the effect keeps using an old, frozen copy of it.
- The effect reads
userId, but the array is empty. - An empty array means run once, so the effect never re-runs when
userIdchanges. - It keeps fetching for the very first
userId, even after the user switches accounts. That’s a stale value.
Look at the broken version next to the fix. The only change is what’s inside the array.
// ❌ Wrong - effect uses userId, but it's not in the arrayuseEffect(() => { fetchProfile(userId);}, []); // keeps fetching the first userId forever
// ✅ Right - list userId, so it re-fetches when the user changesuseEffect(() => { fetchProfile(userId);}, [userId]);So the rule from the React useEffect Dependencies lesson still holds.
- If the effect uses a value, list it.
- Let the
exhaustive-depslint rule catch the ones you miss.
🧹 Mistake 3: forgetting cleanup
This is the one from the cleanup lesson. Your effect starts something that keeps running, but nothing stops it.
- You start a timer with
setInterval, but you never clear it. - When the effect runs again, it starts a second timer on top of the first.
- Old timers keep firing, so you get double and triple updates, plus a memory leak.
Here’s the version with no cleanup next to the version that returns one.
// ❌ Wrong - no cleanup, so timers pile up and never stopuseEffect(() => { setInterval(() => setSeconds((s) => s + 1), 1000);}, []);
// ✅ Right - return a cleanup that clears the timeruseEffect(() => { const id = setInterval(() => setSeconds((s) => s + 1), 1000); return () => clearInterval(id);}, []);So anything you start in an effect, you stop in the cleanup. Timers, event listeners, subscriptions, all of them. The full pattern is back in the React useEffect Cleanup Function lesson.
If you start it, clean it up
Every setInterval needs a clearInterval. Every addEventListener needs a removeEventListener. Every subscription needs an unsubscribe. Return that cleanup from the effect, or the leftovers keep running after the component is gone.
🆕 Mistake 4: deps that change every render
This one is sneaky because the dependency looks correct. You put an object, an array, or a function in the array, but it’s brand new on every render.
- Objects, arrays, and functions created inside the component are made fresh each render.
- Even if the contents look the same, it’s a new value in memory, so React’s check sees it as changed.
- So the effect re-runs on every single render, just like the infinite-loop case. That’s an unstable dependency.
Here’s the object rebuilt each render, then a simple fix.
// ❌ Wrong - a new object every render, so the effect runs every renderconst options = { sort: "asc" };useEffect(() => { runSearch(options);}, [options]);
// ✅ Right - depend on the plain value inside, not the whole objectconst sort = "asc";useEffect(() => { runSearch({ sort });}, [sort]);So the quick fix here is small.
- Depend on the simple value inside, like a string or a number, instead of the whole object.
- For objects and functions you really do need to keep stable, React has
useMemoanduseCallback, which come later in the course.
⏳ Mistake 5: making the effect callback async
You want to fetch data, so you reach for async and put it right on the effect function. That looks natural, but it breaks the rules of useEffect.
- The effect is supposed to return either nothing or a cleanup function.
- An
asyncfunction always returns a promise, not a cleanup function. So React gets the wrong thing back. - React can’t use that promise to clean up, and you get a warning.
The fix is to put an async function inside the effect and call it, instead of making the effect itself async.
// ❌ Wrong - the effect callback itself is asyncuseEffect(async () => { const data = await fetchUser(id); setUser(data);}, [id]);
// ✅ Right - define an async function inside, then call ituseEffect(() => { async function loadUser() { const data = await fetchUser(id); setUser(data); } loadUser();}, [id]);So the effect callback stays normal, and the async work lives in a function inside it. Now the effect can still return a real cleanup if it needs one, and React stays happy.
Keep the effect callback plain
Never write useEffect(async () => ...). Define an async function inside the effect and call it on the next line. That keeps the effect free to return a cleanup function.
🧩 What You’ve Learned
- ✅ Setting state in an effect with no dependency array creates an infinite loop, so give it the right deps or run it once with
[] - ✅ Leaving a value out of the array gives you a stale value, so list everything the effect uses
- ✅ Forgetting cleanup leaves timers, listeners, and subscriptions running, so return a cleanup that stops them
- ✅ An object, array, or function created in the component is new every render, so depend on the plain value inside instead
- ✅ Never make the effect callback
asyncdirectly, define anasyncfunction inside and call it - ✅ Most of these bugs trace back to the dependency array and cleanup, so trust the
exhaustive-depslint rule
Check Your Knowledge
Test what you learned. Pick an answer for each question, then click Check.
- 1
Why does setting state in an effect with no dependency array cause an infinite loop?
Why: With no array the effect runs after every render. Setting state causes a render, which runs the effect again, which sets state again. The loop never stops.
- 2
An effect reads userId but its array is empty []. What is the bug?
Why: An empty array means run once. The effect holds the first userId, a stale value, and never re-runs when the user switches accounts.
- 3
Why does putting an object like { sort: "asc" } in the dependency array re-run the effect every render?
Why: The object is rebuilt on every render, so it is a new value in memory. React's shallow check reports it changed each time, and the effect re-runs every render.
- 4
What is wrong with useEffect(async () => { ... }, [])?
Why: An async function always returns a promise, but useEffect expects either nothing or a cleanup function. Define an async function inside the effect and call it instead.
🚀 What’s Next?
Now you can spot the common useEffect mistakes and fix each one fast. But the biggest lesson hiding behind all of them is this: a lot of effects shouldn’t be effects at all. Next you’ll learn when to skip useEffect completely.