DEV Community

Cover image for How Does useEffect Actually Work?
Tanu Priya
Tanu Priya

Posted on

How Does useEffect Actually Work?

If you've worked with React for a while, you've probably written something like this:

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

It looks simple.

But then the questions start:

  • Why does useEffect run twice in development?
  • Why does it sometimes run again when I didn't expect it?
  • What exactly does the dependency array do?
  • Why does adding a dependency suddenly create an infinite loop?
  • Why do we need a cleanup function?
  • And perhaps the biggest question: when should I use useEffect at all?

Understanding useEffect isn't really about memorizing its syntax.

It's about understanding when React synchronizes your component with something outside of React.


What is useEffect actually for?

The simplest mental model is:

useEffect lets your component synchronize with an external system.

An external system could be:

  • A network connection
  • A WebSocket
  • A browser API
  • A timer
  • A third-party library
  • An event listener
  • A subscription

For example, connecting to a chat server:

useEffect(() => {
  const connection = createConnection(roomId);

  connection.connect();

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

The component isn't just calculating what should appear on the screen.

It's synchronizing something outside React with the current roomId.

That's the important idea.

React's own documentation describes Effects as an "escape hatch" for synchronizing with external systems. If you're not interacting with something external, you may not need an Effect at all. ([React][1])


First: Understand Render vs Effect

This is where useEffect starts making sense.

Imagine:

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

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

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

When userId changes, React roughly goes through:

Props / State change
       ↓
     Render
       ↓
   React commits
       ↓
   Effect runs
       ↓
 External system
Enter fullscreen mode Exit fullscreen mode

The important distinction is:

Rendering calculates the UI.

An Effect synchronizes something outside that rendering process.

Effects run after a commit when their dependencies require synchronization. ([React][1])


The Dependency Array Is Not a "Run When I Want" List

Consider:

useEffect(() => {
  console.log("Hello");
});
Enter fullscreen mode Exit fullscreen mode

There is no dependency array.

That means the Effect can run after every commit.

Now:

useEffect(() => {
  console.log("Hello");
}, []);
Enter fullscreen mode Exit fullscreen mode

The Effect has no reactive dependencies, so it doesn't re-run when props or state change.

And:

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

Now React compares userId with its previous value.

Conceptually:

Previous userId
       ↓
     Compare
       ↓
Current userId
       ↓
Changed?
   /       \
 No         Yes
 ↓           ↓
Skip       Cleanup
             ↓
           Setup
Enter fullscreen mode Exit fullscreen mode

React compares dependency values using Object.is. ([React][1])


You Don't Actually Choose Your Dependencies

This is one of the most important things to understand.

Suppose you write:

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

It may look like you're saying:

"Run this only once."

But your Effect is reading userId, which is a reactive value.

So the dependency should generally be:

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

The dependency list isn't supposed to be a list of values you personally want React to watch.

It's determined by the reactive values your Effect uses.

That's also why suppressing the exhaustive-deps lint rule can hide real bugs rather than solve them. ([React][1])


Cleanup: The Other Half of an Effect

Consider a WebSocket connection:

useEffect(() => {
  const socket = connect(roomId);

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

Why do we return a function?

Because the Effect has two sides:

SETUP
  ↓
Connect to room
  ↓
Use the connection
  ↓
CLEANUP
  ↓
Disconnect
Enter fullscreen mode Exit fullscreen mode

When roomId changes, React doesn't simply run the new setup.

It first cleans up the old Effect:

Old Effect
   ↓
Cleanup
   ↓
New Effect
   ↓
Setup
Enter fullscreen mode Exit fullscreen mode

And when the component is removed, React runs the cleanup one final time. ([React][1])

This makes Effects much easier to reason about.

Instead of thinking:

"What should happen when my component mounts?"

Think:

"What external system am I synchronizing with, and how do I start and stop that synchronization?"


Why Does useEffect Run Twice?

This confuses almost everyone at some point.

You write:

useEffect(() => {
  console.log("Effect");
}, []);
Enter fullscreen mode Exit fullscreen mode

And in development you see:

Effect
Effect
Enter fullscreen mode Exit fullscreen mode

It can look like React is broken.

It's not.

If your application is wrapped in StrictMode, React intentionally runs an extra development-only setup → cleanup → setup cycle for Effects. ([React][2])

Conceptually:

Development + StrictMode

Setup
  ↓
Cleanup
  ↓
Setup
Enter fullscreen mode Exit fullscreen mode

The purpose is to expose Effects that don't clean up correctly.

For example, this is problematic:

useEffect(() => {
  window.addEventListener("resize", handleResize);
}, []);
Enter fullscreen mode Exit fullscreen mode

There is no cleanup.

A better version:

useEffect(() => {
  window.addEventListener("resize", handleResize);

  return () => {
    window.removeEventListener("resize", handleResize);
  };
}, []);
Enter fullscreen mode Exit fullscreen mode

Strict Mode is essentially asking:

"If I start and stop this Effect immediately, does your code still behave correctly?"

That's a very useful development check.


The Infinite Loop Problem

One of the most common useEffect mistakes looks like this:

const [count, setCount] = useState(0);

useEffect(() => {
  setCount(count + 1);
}, [count]);
Enter fullscreen mode Exit fullscreen mode

What's happening?

Effect runs
   ↓
setCount()
   ↓
State changes
   ↓
Component renders again
   ↓
count changed
   ↓
Effect runs again
   ↓
setCount()
   ↓
...
Enter fullscreen mode Exit fullscreen mode

You've created a loop.

The important lesson isn't:

"Never update state inside useEffect."

State updates can be completely valid inside an Effect.

The question is:

Why does this Effect need to update state, and does that state change one of its dependencies?

If the Effect isn't synchronizing with an external system, you may be using an Effect where ordinary React data flow would be simpler. ([React][1])


Be Careful With Objects and Functions

Here's another subtle problem.

function ChatRoom({ roomId }) {
  const options = {
    roomId,
    serverUrl: "https://example.com"
  };

  useEffect(() => {
    connect(options);
  }, [options]);
}
Enter fullscreen mode Exit fullscreen mode

options is created during every render.

So even if the contents look identical:

Render 1 → options object A
Render 2 → options object B
Render 3 → options object C
Enter fullscreen mode Exit fullscreen mode

They're different object references.

That can cause the Effect to re-run unnecessarily.

A common improvement is to create the object inside the Effect:

useEffect(() => {
  const options = {
    roomId,
    serverUrl: "https://example.com"
  };

  connect(options);
}, [roomId]);
Enter fullscreen mode Exit fullscreen mode

The same issue can happen with functions created during rendering.

This is why blindly adding useCallback or useMemo isn't always the answer. First understand why the dependency changes. ([React][1])


useEffect Isn't Your Event Handler

This distinction is extremely useful.

An event handler responds to an interaction:

function handleClick() {
  saveDocument();
}
Enter fullscreen mode Exit fullscreen mode

The user clicked something.

An Effect responds to synchronization caused by rendering:

useEffect(() => {
  connectToRoom(roomId);

  return () => disconnectFromRoom(roomId);
}, [roomId]);
Enter fullscreen mode Exit fullscreen mode

The room ID changed, so the external connection needs to synchronize with it.

A useful mental model:

Event Handler
    ↓
User interaction
    ↓
Do something


Effect
    ↓
Rendered state changed
    ↓
Synchronize with external system
Enter fullscreen mode Exit fullscreen mode

Effects are not meant to become a second event system for your application.


A Better Way to Think About useEffect

Instead of asking:

"When does my component mount?"

Ask:

"When does this synchronization need to start, and when does it need to stop?"

For example:

WebSocket

roomId changes
      ↓
Disconnect old room
      ↓
Connect new room
Enter fullscreen mode Exit fullscreen mode

Timer

Component starts
      ↓
Start timer
      ↓
Component stops
      ↓
Clear timer
Enter fullscreen mode Exit fullscreen mode

Event listener

Setup listener
      ↓
Component remains active
      ↓
Remove listener
Enter fullscreen mode Exit fullscreen mode

Subscription

Subscribe
   ↓
Receive updates
   ↓
Unsubscribe
Enter fullscreen mode Exit fullscreen mode

This mental model is much more powerful than memorizing:

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

What About Fetching Data?

You can fetch data inside an Effect:

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

But this doesn't mean:

"Every API call should use useEffect."

Modern React applications often use framework-level data fetching or dedicated data-fetching libraries because they can handle caching, deduplication, loading states, and server rendering more effectively.

So before writing:

useEffect(() => {
  fetch(...);
}, []);
Enter fullscreen mode Exit fullscreen mode

ask whether your framework already provides a better place to fetch that data.


The useEffect Checklist

Before adding an Effect, ask:

1. Am I synchronizing with something outside React?

If no, you may not need an Effect.

2. What starts the synchronization?

For example:

roomId changes
Enter fullscreen mode Exit fullscreen mode

3. What stops it?

For example:

disconnect()
Enter fullscreen mode Exit fullscreen mode

4. Which reactive values does the Effect read?

Those generally belong in the dependency list.

5. Can the Effect run more than once safely?

It should.

6. Does cleanup undo the setup?

If you connect, disconnect.

If you subscribe, unsubscribe.

If you add a listener, remove it.


The Mental Model I Wish I Knew Earlier

useEffect isn't:

"Run this code after the component renders."

That's incomplete.

A better mental model is:

useEffect is a synchronization mechanism between React and the outside world.

The Effect has a lifecycle:

       Reactive Values
             ↓
          Render
             ↓
           Commit
             ↓
       Effect Setup
             ↓
     External System
             ↓
   Dependency Changes?
        /          \
      No            Yes
      ↓              ↓
   Continue       Cleanup
                     ↓
                   Setup
Enter fullscreen mode Exit fullscreen mode

Once you understand this, many confusing useEffect behaviors become much easier to explain.


Final Takeaway

useEffect is one of those React APIs that looks tiny but has a surprisingly deep mental model.

The syntax is easy:

useEffect(() => {
  // synchronize
  return () => {
    // clean up
  };
}, [dependencies]);
Enter fullscreen mode Exit fullscreen mode

The difficult part is deciding whether you need it in the first place.

When you do need it, think in terms of:

Setup → Synchronize → Cleanup

Not:

Mount → Do something → Hope it doesn't run again.

And when React runs your Effect more than once in development, don't immediately try to stop it.

Instead, ask:

"Is my Effect written so that setup and cleanup can safely happen more than once?"

If the answer is yes, you're probably thinking about Effects the right way.

Good React code isn't about using more Hooks. It's about understanding why each Hook exists.

Top comments (0)