I used useEffect for months before I actually understood it.
I knew the syntax. I could make it work. But I had a collection of habits I'd picked up by trial and error — like always adding // eslint-disable-next-line react-hooks/exhaustive-deps when the linter complained, or passing an empty array [] whenever I just wanted something to run once.
It worked. Until it didn't. And when it didn't, I had no idea why.
This post is the explanation I wish I'd had from the start.
What useEffect Actually Is
Most tutorials explain useEffect as a way to run "side effects" — data fetching, subscriptions, manually touching the DOM. That's true, but it doesn't help you think about it correctly.
Here's a better mental model:
useEffectlets you synchronize your component with something outside of React.
That "something" could be an API, a browser API like document.title, a timer, a WebSocket — anything that lives outside React's render cycle. Every time your component renders, React checks whether the things your effect depends on have changed. If they have, it re-runs the effect.
That reframe changes everything. You're not "running code after render." You're keeping an external system in sync with your component's state.
The Dependency Array — What It Actually Means
This is where most people (including me) get confused.
useEffect(() => {
document.title = `Hello, ${name}`;
}, [name]);
The second argument — [name] — is the dependency array. It tells React: "re-run this effect whenever name changes."
There are three forms:
No dependency array — runs after every single render:
useEffect(() => {
console.log('This runs every render');
});
Empty array [] — runs once, after the first render:
useEffect(() => {
console.log('This runs once on mount');
}, []);
Array with values — runs when any of those values change:
useEffect(() => {
fetchUser(userId);
}, [userId]);
The rule is simple: every value from your component that the effect uses should be in the dependency array. Props, state, context — if your effect reads it, it belongs in the array.
The reason people reach for eslint-disable is they don't want the effect to re-run when a dependency changes. But that's usually a symptom of a different problem — either the effect is doing too much, or the component state isn't structured correctly.
The Bug I Kept Creating
Here's a real example of the mistake I made over and over:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data));
}, []); // ⚠️ Missing userId in deps
return <div>{user?.name}</div>;
}
This looks fine. It fetches the user on mount. But what happens when userId changes — like when you navigate from one user's profile to another? The effect doesn't re-run. You're still showing the old user's data.
The fix is obvious once you see it:
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data));
}, [userId]); // ✅ Now re-fetches when userId changes
Cleanup Functions — The Part Everyone Skips
Every useEffect can optionally return a function. That function runs when the component unmounts, or before the effect runs again.
useEffect(() => {
const timer = setInterval(() => {
setCount(c => c + 1);
}, 1000);
return () => clearInterval(timer); // cleanup
}, []);
Without the cleanup, that interval keeps running even after the component is gone — a classic memory leak.
The same pattern applies to subscriptions, event listeners, and WebSockets:
useEffect(() => {
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
Think of the cleanup as: "before I run this effect again, undo what the last run did."
The Race Condition Nobody Warns You About
Here's a subtle bug that useEffect makes easy to accidentally create:
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data)); // ⚠️ Could set stale data
}, [userId]);
Imagine userId changes quickly — from 1 to 2. Two fetches start. If the fetch for user 1 completes after the fetch for user 2, you end up displaying user 1's data even though the component should be showing user 2.
The fix is to use a cleanup flag:
useEffect(() => {
let cancelled = false;
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
if (!cancelled) setUser(data);
});
return () => { cancelled = true; };
}, [userId]);
Now if the effect re-runs before the fetch completes, the previous fetch's result is ignored. Clean, no library required.
A Mental Checklist
Before I finalize any useEffect, I now ask myself:
-
What external system am I syncing with? If I can't answer this clearly, maybe I don't need a
useEffectat all. - Does my dependency array include everything the effect reads? If not, I'm asking for stale data bugs.
- Does this effect create anything that needs cleanup? Timers, listeners, subscriptions — always clean up.
- Can this effect cause a race condition? If it involves async, think about what happens if it runs twice.
When You Don't Need useEffect
One thing I've learned — the best useEffect is often the one you don't write.
Derived state, computed values, even some event responses don't need useEffect at all. Before reaching for it, ask: can this be calculated directly during render? Can it go in an event handler instead?
useEffect is powerful, but it's also a common source of bugs when overused. Use it for synchronization with external systems — and for not much else.
I'm Animesh, a CS final year student learning React and full-stack development. If this helped, follow me here on dev.to for more posts like this.
Top comments (0)