Effects

Published:

Rendering is a calculation. It creates local values and returns a description. Connections, timers, and browser commands belong to a later stage.

Some components still need to do exactly those things. A chat panel describes a room and remains connected to it while the panel is present.

That leaves the first hole:

if rendering is supposed to stay pure, where does synchronization with the outside world go?

The connection that starts too early #

Suppose a small connection object stands in for a browser or network API:

const { useState } = React;

function createConnection(roomId) {
  return {
    connect() {
      console.log('connect:', roomId);
    },
    disconnect() {
      console.log('disconnect:', roomId);
    },
  };
}

function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState(0);

  const connection = createConnection(roomId);
  connection.connect();

  return (
    <button onClick={() => setMessages(messages + 1)}>
      {roomId}: {messages} messages
    </button>
  );
}

root.render(<ChatRoom roomId="general" />);

setTimeout(() => {
  mountNode.querySelector('button').click();
}, 20);

Waiting to run

Not run yet.

The first render connects to general. Clicking only changes the message count, but the next render connects again.

The component has confused two jobs:

  • rendering calculates what the chat panel should look like
  • connecting changes a system outside React

React may call rendering logic more often than the screen changes. It may also abandon a calculated result before commit. An external action performed during render would remain, detached from any committed UI that required it.

The experiment gives us the first constraint:

external synchronization begins after React commits the description that requires it

An event handler runs outside render when a particular user event owns the action. Here, the component's presence owns the connection. The panel might appear because its parent rendered it, a route changed, or restored state selected the room. The requirement is to keep a connection synchronized with the committed ChatRoom.

After the commit #

useEffect accepts a setup function. React runs that setup after it commits the rendered result.[1]

Move the connection into an Effect:

const { useEffect } = React;

function createConnection(roomId) {
  return {
    connect() {
      console.log('connect:', roomId);
    },
  };
}

function ChatRoom({ roomId }) {
  console.log('render:', roomId);

  useEffect(() => {
    const connection = createConnection(roomId);
    connection.connect();
    console.log('committed DOM:', mountNode.textContent);
  });

  return <p>Room: {roomId}</p>;
}

root.render(<ChatRoom roomId="general" />);

Waiting to run

Not run yet.

The order is visible in the output:

  1. React calls ChatRoom, which registers the Effect and returns a paragraph.
  2. React commits the paragraph to the DOM.
  3. React runs the Effect setup, which can see the committed text.

Calling useEffect records React-managed behavior at a stable call position, like other Hooks. React runs the setup function later, outside render.[2]

The timing hole is filled: the connection begins after commit. The Effect still runs after every commit, so an unrelated render gives us the next test.

The unrelated render #

Add a theme toggle to the room:

const { useEffect, useState } = React;

function ChatRoom({ roomId }) {
  const [dark, setDark] = useState(false);

  useEffect(() => {
    console.log('connect:', roomId);
  });

  return (
    <section className={dark ? 'dark' : 'light'}>
      <p>Room: {roomId}</p>
      <button onClick={() => setDark(!dark)}>
        Toggle theme
      </button>
    </section>
  );
}

root.render(<ChatRoom roomId="general" />);

setTimeout(() => {
  mountNode.querySelector('button').click();
}, 20);

Waiting to run

Not run yet.

Toggling the theme logs connect: general again. The committed room stayed the same, yet the Effect repeated its external action.

That exposes a second hole:

how can React tell which rendered values can change this synchronization?

useEffect accepts a second argument: a dependency list. It describes which reactive values the synchronization reads, so React can compare them with the previous render.[4]

useEffect(() => {
  console.log('connect:', roomId);
}, [roomId]);

After a commit, React compares each dependency with its value from the previous render using Object.is.[2]

  • if roomId is unchanged, React leaves the existing synchronization alone
  • if roomId changed, React runs a new setup

The dependency list identifies the reactive inputs read by setup. Props, state, and values declared inside the component are reactive because a later render may give them different values.[3]

Keeping the connection current #

Perhaps every Effect can use an empty list and run once for the component's lifetime. Put that prediction into a live room switcher:

const { useEffect, useState } = React;

let activeRoom = null;

function ChatRoom({ roomId }) {
  useEffect(() => {
    activeRoom = roomId;
    console.log('connect:', roomId);
  }, []);

  return <p>Room on screen: {roomId}</p>;
}

function App() {
  const [roomId, setRoomId] = useState('general');

  return (
    <div>
      <ChatRoom roomId={roomId} />
      <button onClick={() => setRoomId('travel')}>
        Go to travel
      </button>
    </div>
  );
}

root.render(<App />);

setTimeout(() => {
  mountNode.querySelector('button').click();

  setTimeout(() => {
    console.log(mountNode.querySelector('p').textContent);
    console.log('active connection:', activeRoom);
  }, 0);
}, 20);

Waiting to run

Not run yet.

The screen reaches travel; the connection remains in general. The empty list gave the setup one lifetime, while roomId needed to give it a new lifetime.

The mismatch reveals the constraint:

every reactive value read by an Effect belongs in its dependency list

Using [roomId] keeps both sides aligned by running setup whenever the room changes.

The three common forms have precise meanings:

useEffect(setup);             // synchronize after every commit
useEffect(setup, []);         // synchronize for this mount only
useEffect(setup, [roomId]);   // resynchronize when roomId changes

The correct list contains every reactive value used by the Effect. React's Hooks linter can verify that list. When a dependency causes too many reruns, restructure the surrounding code so the Effect reads a smaller set of reactive values and its dependency list remains truthful.[4]

Values created outside the component remain stable across renders. The dependency list therefore contains only the reactive inputs:

const serverUrl = 'https://chat.example.com';

function ChatRoom({ roomId }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);
}

Moving a constant outside the component establishes that every render receives the same value.

The connection left behind #

[roomId] starts the next connection at the right time. Watch the active connections as the room changes:

const { useEffect, useState } = React;

const activeRooms = new Set();

function createConnection(roomId) {
  return {
    connect() {
      activeRooms.add(roomId);
      console.log('active:', [...activeRooms].join(', '));
    },
    disconnect() {
      activeRooms.delete(roomId);
      console.log('active:', [...activeRooms].join(', '));
    },
  };
}

function ChatRoom({ roomId }) {
  useEffect(() => {
    const connection = createConnection(roomId);
    connection.connect();
  }, [roomId]);

  return <p>Connected to {roomId}</p>;
}

function App() {
  const [roomId, setRoomId] = useState('general');

  return (
    <div>
      <ChatRoom roomId={roomId} />
      <button onClick={() => setRoomId('travel')}>
        Go to travel
      </button>
    </div>
  );
}

root.render(<App />);

setTimeout(() => {
  mountNode.querySelector('button').click();
}, 20);

Waiting to run

Not run yet.

The second setup connects to travel, and general stays active. Dependencies decide when setup runs. Releasing work from the previous setup requires one more part of the Effect protocol.

That opens the lifetime hole:

how does one synchronization release its resource before the next one begins?

An Effect setup may return a cleanup function. React runs cleanup before a replacement setup and when the component leaves the tree.[2]

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

  return () => {
    connection.disconnect();
  };
}, [roomId]);

Changing rooms now produces:

active: general
active:
active: travel

The cleanup closes over the exact connection created by its setup. It already owns everything required to undo that setup.

The dependency list covers the whole process: every reactive value read by setup or cleanup belongs in it.

This gives us the next constraint:

setup and cleanup form one reversible synchronization process

Connections disconnect, subscriptions unsubscribe, observers disconnect, and timers are cleared. A setup that starts an ongoing process should usually pair with its inverse. Effects that complete immediately may require only setup.

Two processes with one lifetime #

The connection is now correct. Add visit recording to the same Effect:

const { useEffect, useState } = React;

function Dashboard({ roomId, userId }) {
  useEffect(() => {
    console.log('connect:', roomId);
    console.log('record visit:', userId);

    return () => console.log('disconnect:', roomId);
  }, [roomId, userId]);

  return <p>{userId} in {roomId}</p>;
}

function App() {
  const [userId, setUserId] = useState('maya');

  return (
    <div>
      <Dashboard roomId="general" userId={userId} />
      <button onClick={() => setUserId('grace')}>
        Switch user
      </button>
    </div>
  );
}

root.render(<App />);

setTimeout(() => {
  mountNode.querySelector('button').click();
}, 20);

Waiting to run

Not run yet.

Switching the user disconnects and reconnects the unchanged room. The dependency list is truthful: the Effect reads both values. The hole lies in the Effect's scope. One lifetime has coupled two independent processes.

Separate Effects give each process its own reactive lifetime:[3]

useEffect(() => {
  console.log('connect:', roomId);
  return () => console.log('disconnect:', roomId);
}, [roomId]);

useEffect(() => {
  console.log('record visit:', userId);
}, [userId]);

Changing userId now repeats only visit recording. The result gives us another constraint:

one Effect describes one independent synchronization process

The extra render #

Effects now correspond to external processes. Perhaps they can also keep one state value synchronized with other state values. Try deriving a full name in an Effect:

const { useEffect, useState } = React;

function Profile() {
  const [firstName, setFirstName] = useState('Maya');
  const [lastName, setLastName] = useState('Chen');
  const [fullName, setFullName] = useState('');

  console.log('render fullName:', JSON.stringify(fullName));

  useEffect(() => {
    setFullName(firstName + ' ' + lastName);
  }, [firstName, lastName]);

  return <p>{fullName}</p>;
}

root.render(<Profile />);

Waiting to run

Not run yet.

The first render commits an empty name. The Effect then updates fullName, causing a second render and commit. Every change to either name repeats that two-step path.

The experiment exposes a category error: every value involved already belongs to React's calculation. The current render has enough information to produce the full name immediately.

Calculate it directly while rendering:[5]

function Profile() {
  const [firstName, setFirstName] = useState('Maya');
  const [lastName, setLastName] = useState('Chen');
  const fullName = `${firstName} ${lastName}`;

  return <p>{fullName}</p>;
}

One calculation now produces one consistent description. That gives us the boundary:

values derived from current render inputs belong in render

The action that repeats #

One final attempt turns a button click into state and lets an Effect send the message. Remount the composer after sending once:

const { useEffect, useState } = React;

function Composer({ sendRequested }) {
  useEffect(() => {
    if (sendRequested) {
      console.log('send: Hello');
    }
  }, [sendRequested]);

  return <p>Message: Hello</p>;
}

function App() {
  const [sendRequested, setSendRequested] = useState(false);
  const [version, setVersion] = useState(0);

  return (
    <div>
      <Composer key={version} sendRequested={sendRequested} />
      <button onClick={() => setSendRequested(true)}>Send</button>
      <button onClick={() => setVersion(version + 1)}>Remount</button>
    </div>
  );
}

root.render(<App />);

setTimeout(() => {
  const buttons = mountNode.querySelectorAll('button');
  buttons[0].click();

  setTimeout(() => {
    buttons[1].click();
  }, 0);
}, 20);

Waiting to run

Not run yet.

One click produces two sends. sendRequested records a lasting condition, so the newly mounted composer synchronizes with that condition again. Sending, however, belongs to the moment of the click.

Put the action in the handler that owns its cause:

function Composer() {
  const [message, setMessage] = useState('');

  function handleSend() {
    sendMessage(message);
  }

  return (
    <>
      <input
        value={message}
        onChange={(event) => setMessage(event.currentTarget.value)}
      />
      <button onClick={handleSend}>Send</button>
    </>
  );
}

The handler produces one send for one interaction. This closes the final boundary hole:

actions caused by a particular interaction belong in that event handler

The useful boundary is:

  • use rendering to derive the next UI from props, state, and context
  • use event handlers for actions caused by a particular interaction
  • use Effects to keep a committed component synchronized with an external system

Thinking in start and stop #

Class lifecycle names encouraged a timeline: mount, update, unmount. An Effect is easier to reason about as one synchronization process that may start and stop several times.[3]

For the room connection:

general setup
general cleanup
travel setup
travel cleanup

Each setup owns its cleanup. A single setup and setup → cleanup → setup should produce equivalent observable behavior, apart from the temporary release of the resource between them. That property lets React probe Effects with an additional development-only cycle and expose missing cleanup.[a]

The useful design question is:

can this synchronization start, stop, and start again while releasing every resource and staying current?

Filling the hole #

The experiments forced a small protocol into view:

  1. rendering calculates a description and leaves external systems unchanged
  2. React commits that description
  3. an Effect setup synchronizes an external system with the committed result
  4. the dependency list tells React when the inputs to that synchronization changed
  5. cleanup stops the previous synchronization before replacement or unmount

Effects belong after commit because only committed UI should acquire external consequences. Dependencies keep those consequences aligned with current reactive values. Cleanup makes their lifetime reversible.

Final definition #

An Effect is a React-managed synchronization process between a committed component and a system outside React. Its setup starts or updates that synchronization after commit, its dependencies identify the reactive values that can require replacement, and its cleanup stops the previous synchronization.

Effects are an escape hatch. Calculated values stay in render, event-caused actions stay in handlers, and external synchronization belongs in Effects.

Summary #

Effects fill the external-synchronization hole:

  • rendering must remain a pure calculation
  • external synchronization starts after commit
  • useEffect setup runs after React commits
  • cleanup runs before replacement and when the component unmounts
  • omitting the dependency list synchronizes after every commit
  • an empty list applies when the Effect reads only stable values
  • a dependency list must include every reactive value the Effect reads
  • independent external processes belong in independent Effects
  • derived values should be calculated during render
  • user-caused actions usually belong in event handlers
  • correct cleanup makes repeated setup safe

Notes

  1. The live demos in this article use the React 19.2.5 and react-dom 19.2.5 development modules. In development, Strict Mode may run one additional setup-and-cleanup cycle before the first real setup. Correct cleanup makes that probe observationally equivalent to one setup. · Back
  2. `useLayoutEffect` runs at a different point around browser layout and paint. It is useful for particular visual measurements, but this article develops the ordinary `useEffect` model first.

References

  1. React: Synchronizing with Effects (opens in a new tab) · Back
  2. React `useEffect` API reference (opens in a new tab) · Back
  3. React: Lifecycle of Reactive Effects (opens in a new tab) · Back
  4. React: Removing Effect Dependencies (opens in a new tab) · Back
  5. React: Deriving values during render (opens in a new tab) · Back