DEV Community

Cover image for Stop Writing useEffect Wrong: The Mental Model That Changes Everything
Teguh Coding
Teguh Coding

Posted on

Stop Writing useEffect Wrong: The Mental Model That Changes Everything

Every React developer has been there. You write a useEffect, it seems to work, and then — chaos. Infinite loops, stale data, components behaving in ways you cannot explain. You copy a Stack Overflow fix, sprinkle in some eslint-disable comments, and move on.

But the problem was never the code. It was the mental model.

The Wrong Way to Think About useEffect

Most developers learn useEffect as: "run this code when something changes." That framing sounds reasonable, but it leads you directly into trouble.

When you think of useEffect as a lifecycle hook — something that fires after a render because you told it to — you start treating it like componentDidMount or componentDidUpdate. You reach for it to "sync" things, to "trigger" side effects, to "watch" for changes.

That mental model is wrong. And it costs you weeks of debugging.

The Right Mental Model: Synchronization

Here is the shift that changes everything:

useEffect is not about reacting to events. It is about synchronizing your component with something outside React.

The question is not "when should this run?" The question is: "What external system needs to stay in sync with this state?"

External systems include:

  • Browser APIs (document title, localStorage, scroll position)
  • Third-party libraries (maps, charts, animation libraries)
  • Network connections (WebSockets, subscriptions)
  • Timers and intervals

If what you are doing does not involve an external system, you probably do not need useEffect at all.

A Practical Example: The Classic Mistake

Here is a pattern you have definitely written or seen:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => setUser(data));
  }, [userId]);

  return <div>{user?.name}</div>;
}
Enter fullscreen mode Exit fullscreen mode

This looks fine. But watch what happens when userId changes rapidly — maybe you have a list and the user clicks through quickly. You get a race condition. The responses arrive out of order, and you display the wrong user.

Applying the correct mental model: you are synchronizing this component with a remote data source. That means you also need to handle the case where the synchronization is interrupted.

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    let cancelled = false;

    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => {
        if (!cancelled) {
          setUser(data);
        }
      });

    return () => {
      cancelled = true;
    };
  }, [userId]);

  return <div>{user?.name}</div>;
}
Enter fullscreen mode Exit fullscreen mode

The cleanup function is the key insight. Every effect that synchronizes with something external should also know how to un-synchronize — how to clean up when the component unmounts or when the dependency changes.

The Three Questions to Ask Before Writing useEffect

Before you reach for useEffect, ask yourself:

1. Am I transforming data for rendering?

If yes, do it during render or with useMemo. You do not need useEffect for this.

// Wrong
const [fullName, setFullName] = useState('');
useEffect(() => {
  setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);

// Right
const fullName = `${firstName} ${lastName}`;
Enter fullscreen mode Exit fullscreen mode

2. Am I handling a user event?

If yes, handle it in the event handler, not in an effect.

// Wrong
const [submitted, setSubmitted] = useState(false);
useEffect(() => {
  if (submitted) {
    sendFormData(data);
    setSubmitted(false);
  }
}, [submitted]);

// Right
function handleSubmit() {
  sendFormData(data);
}
Enter fullscreen mode Exit fullscreen mode

3. Am I synchronizing with an external system?

If yes, useEffect is appropriate — and make sure to return a cleanup function.

The Dependency Array Is Not a Filter

Another massive misconception: developers treat the dependency array as a way to control when the effect runs. They omit dependencies to prevent "too many" runs. They add // eslint-disable-next-line to silence the linter.

The dependency array is not a filter. It is a declaration.

You are telling React: "Here are all the values from the React world that this effect uses." React uses that information to know when the external system might be out of sync and needs to re-synchronize.

When you lie to the dependency array, you get stale closures — your effect captures old values and acts on them instead of the current ones. The bugs that result are notoriously hard to diagnose.

If your effect runs too often and that feels wrong, the real problem is usually one of these:

  • An object or array is being recreated on every render (use useMemo or move it outside the component)
  • You actually need to split it into two separate effects
  • You do not need an effect at all

When useEffect Runs in Development vs Production

In React 18 with Strict Mode enabled (which is the default in new projects), effects run twice on mount in development. This trips up a lot of developers.

React does this intentionally — it mounts, unmounts, then mounts again to help you find bugs where you forgot to write cleanup functions. If your effect breaks when run twice, that is the bug React is helping you catch.

If your effect looks like this and breaks on double-mount:

useEffect(() => {
  const connection = createConnection();
  connection.connect();
}, []);
Enter fullscreen mode Exit fullscreen mode

Add the cleanup:

useEffect(() => {
  const connection = createConnection();
  connection.connect();

  return () => {
    connection.disconnect();
  };
}, []);
Enter fullscreen mode Exit fullscreen mode

Now it works correctly in both development and production.

Alternatives Worth Knowing

Before reaching for useEffect, consider whether one of these fits better:

  • useSyncExternalStore — for subscribing to external stores (Redux, Zustand, browser APIs like window.innerWidth)
  • useTransition and useDeferredValue — for keeping the UI responsive during heavy updates
  • React Query / SWR — for server data synchronization; these libraries handle all the edge cases (caching, deduplication, stale data, revalidation) that raw useEffect fetching misses

For most data fetching scenarios, reaching for React Query or SWR is the right call. It is not a shortcut — it is using the right tool.

The Takeaway

Once you internalize that useEffect is about synchronization with external systems, everything clicks:

  • You know when to use it (external system involved) and when not to (pure React state transforms, event handling)
  • You know why cleanup matters (un-synchronizing when done)
  • You know why the dependency array must be complete (it declares what React state the external system depends on)
  • You understand the double-invoke in Strict Mode (React is verifying your cleanup)

Stop thinking of useEffect as a lifecycle hook. Start thinking of it as a bridge between React and the outside world — and always clean up when you leave.

Your future self, debugging at 2 AM, will thank you.

Top comments (0)