DEV Community

Animesh Sarker (231-115-074)
Animesh Sarker (231-115-074)

Posted on

Understanding useEffect — The Hook I Got Wrong for Months

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:

useEffect lets 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]);
Enter fullscreen mode Exit fullscreen mode

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');
});
Enter fullscreen mode Exit fullscreen mode

Empty array [] — runs once, after the first render:

useEffect(() => {
  console.log('This runs once on mount');
}, []);
Enter fullscreen mode Exit fullscreen mode

Array with values — runs when any of those values change:

useEffect(() => {
  fetchUser(userId);
}, [userId]);
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}, []);
Enter fullscreen mode Exit fullscreen mode

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);
}, []);
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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:

  1. What external system am I syncing with? If I can't answer this clearly, maybe I don't need a useEffect at all.
  2. Does my dependency array include everything the effect reads? If not, I'm asking for stale data bugs.
  3. Does this effect create anything that needs cleanup? Timers, listeners, subscriptions — always clean up.
  4. 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)