
You're Probably Using useEffect Wrong
If you’re like most React devs, your first dance with useEffect
probably went something like this:
useEffect(() => {
// do the thing
}, []);
Then someone said, “Hey, don’t forget your dependencies,” so you added a few things:
useEffect(() => {
doSomething(data);
}, [data]);
Then things got weird. It fired too often. Or not often enough. Or twice in dev mode. Or caused an infinite loop. So you did what every dev does when confronted with the unknown: you started guessing.
Let’s un-guess some things.
What useEffect
Actually Is
useEffect
is React's way of syncing side effects with state or prop changes. It’s basically saying:
“When these specific values change, run this function. But only then.”
It’s not a lifecycle method. It’s not a data-fetching hook. It’s just a sync tool. But because it's so flexible, people end up using it for everything—and that's where things go sideways.
Common Pitfall: Running Effects That Don't Need to Be Effects
Let’s say you’re updating the document title:
useEffect(() => {
document.title = `Hello, ${name}`;
}, [name]);
This works. But it’s synchronous. It doesn't need to be a side effect unless you're reacting to async data or triggering something outside React's render cycle.
A cleaner version might just be:
document.title = `Hello, ${name}`;
Inside the component body, during render. That’s perfectly valid if it’s simple and doesn’t require cleanup.
What Actually Belongs in useEffect
?
Here’s your gut check:
“Does this code need to happen after render, or in response to something changing?”
If yes, it's a good use case. If no, you're probably cramming logic into useEffect
that could live elsewhere.
Examples that do belong:
- Fetching data on mount
- Subscribing to a service or event
- Setting timeouts/intervals
- Manually changing the DOM (with third-party libs, etc.)
- Anything that needs cleanup (e.g. returning a function from
useEffect
)
The Double Render in Dev Mode (You're Not Crazy)
React’s Strict Mode in development will call your effect twice. Not a bug—it’s intentional. It wants to make sure your effect logic is pure and doesn’t have unintended consequences. But it makes things like API calls super annoying.
If you're seeing duplicate fetches, use a flag or an abort controller:
useEffect(() => {
let ignore = false;
async function fetchData() {
const res = await fetch("/api/data");
const data = await res.json();
if (!ignore) setData(data);
}
fetchData();
return () => { ignore = true; };
}, []);
The Cleanup Pattern
If your effect sets up anything that persists (like a subscription, interval, or event listener), clean it up:
useEffect(() => {
const id = setInterval(() => {
console.log("tick");
}, 1000);
return () => clearInterval(id); // 🔥 don't forget this
}, []);
Without cleanup, you're gonna have a bad time.
TL;DR Rules for useEffect
- Don’t use it just because you “might need it later.”
- If it can be done during render, do it there.
- Keep effects small and focused—split them if needed.
- Use cleanup religiously.
- Respect the dependencies array (no cheating with
// eslint-disable-line
unless you're really sure).
That’s it. Try removing one useEffect
from your project today. You might not need it after all.