Command Palette

Search for a command to run...

Hectal
PHASE 7Intermediate React 16.8+ ~21 min· topic 5 of 9

Topic 7.5

The Lifecycle of an Effect

In one line

A component mounts, updates and unmounts, but an effect only does two things: start syncing and stop syncing; think of each effect on its own, list the reactive values it reads, and React re-syncs it whenever one of them changes.

Think of it like this

A radio tuned to a station. You don't think "the radio was born, the radio updated, the radio died". You think: "right now it's tuned to 98.3". Change the station and it stops playing 98.3 and starts playing 101.1. Switch it off and it stops. An effect is a radio: it is always in sync with its current inputs, and changing an input means stop the old sync, start the new one.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Mount
A component appearing on the screen for the first time.
Update
A component rendering again with new props or state.
Unmount
A component being removed from the screen.
Synchronise
Make something outside React match the component's current values.
Re-synchronise
Stop syncing with the old values (cleanup) and start with the new ones (setup).
Reactive value
A prop, a piece of state, or anything calculated from them inside the component.
Concern
One independent job, such as "connect to the chat" or "log a page visit".

Step by step

01Component lifecycle vs effect lifecycle

The component mounts once and unmounts once. Its effect may start and stop many times in between, once per change of its dependencies.

Component lifecycle vs effect lifecyclediagram
Rendering diagram…

02Write the effect for one set of values

Read the effect alone: it connects to roomId on serverUrl and disconnects from the same pair. That's all it needs to know.

ChatRoom.jsxwhole filejsx
useEffect(() => {
  const connection = createConnection(serverUrl, roomId);
  connection.connect();          // start syncing
  return () => connection.disconnect(); // stop syncing
}, [serverUrl, roomId]);

03Find the reactive values

roomId is a prop: reactive. serverUrl is calculated from state: reactive. DEFAULT_PORT is declared outside the component: not reactive. createConnection is imported: not reactive. So the array is [serverUrl, roomId].

ChatRoom.jsxwhole filejsx
const DEFAULT_PORT = 1234; // not reactive

function ChatRoom({ roomId }) {               // roomId: reactive
  const [host, setHost] = useState("localhost");
  const serverUrl = "https://" + host + ":" + DEFAULT_PORT; // reactive
  // ...
}

04Split unrelated jobs

Logging a visit depends only on roomId. Connecting depends on roomId and serverUrl. In one effect, changing the server would log a fake visit. In two effects, each re-runs only when its own inputs change.

ChatRoom.jsxwhole filejsx
useEffect(() => {
  logVisit(roomId);
}, [roomId]);

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

Try it yourself

  1. 1

    Make the port reactive

    In the first example, move const DEFAULT_PORT = 1234; inside ChatRoom. Is it reactive now? (No: it's a literal that is the same on every render. The linter knows this and doesn't ask you to list it. Keeping true constants outside the component still makes that obvious to readers.)

  2. 2

    Switch back and forth

    Click Switch room twice in the first example. Predict the four log lines before you click.

  3. 3

    Change the room in the split demo

    Give Demo a room state and a button that changes it. Predict which effects of Split re-run (both) and which of Combined (its only effect).

Code & diagrams

Any reactive value change re-synchronises New tab

The component never unmounts here. Its effect still stops and starts twice: once because state (`host`, through `serverUrl`) changed, once because a prop (`roomId`) changed. The effect doesn't care which.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

start syncing: #general at https://localhost:1234
--- switch server
stop syncing: #general at https://localhost:1234
start syncing: #general at https://chat.example:1234
--- switch room
stop syncing: #general at https://chat.example:1234
start syncing: #travel at https://chat.example:1234
One effect per concern New tab

Changing the server makes `Combined` log a second visit to #general that never happened, polluting the analytics. `Split` only reconnects. Notice also that both cleanups run before any new setup.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Combined: analytics, visited #general
Combined: connect #general via eu
Split: analytics, visited #general
Split: connect #general via eu
--- change server
Combined: disconnect #general via eu
Split: disconnect #general via eu
Combined: analytics, visited #general
Combined: connect #general via us
Split: connect #general via us

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Think "on mount" and use []

In the first example, change the dependency array to [] ("connect on mount").

terminal
$ npx eslint + Console
── what you'll see ──
warning React Hook useEffect has missing dependencies: 'roomId' and 'serverUrl'. Either include them or remove the dependency array react-hooks/exhaustive-deps
 
Console: only "start syncing: #general at https://localhost:1234"; switching server or room prints nothing.

Myth vs fact

Myth

useEffect(fn, []) is componentDidMount.

Fact

It's "an effect that reads no reactive values". It also runs its cleanup and setup again in StrictMode and in features like <Activity>. Don't plan logic around mount timing.

Myth

One component should have one effect.

Fact

One effect per concern. A component may have several effects, each syncing one thing with its own dependencies.

Myth

Putting location.pathname or ref.current in deps makes the effect react to them.

Fact

React only checks deps after a render. Changes to those values don't cause renders, so the effect won't notice them.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Class components needed the same logic in componentDidMount and componentDidUpdate (with a manual prevProps.roomId !== this.props.roomId check) and teardown in componentWillUnmount. Hooks collapse those three into one symmetric effect, which is why the synchronising view was introduced with hooks in React 16.8.

  • ▸

    Splitting effects by concern also improves custom hooks: each concern becomes a useSomething hook with its own inputs (Phase 10).

  • ▸

    The "effect re-syncs any time" contract is what lets React add features such as <Activity> and Fast Refresh without changing your code: they just stop and start effects.

Remember this

  1. 1

    A component's lifecycle has three moments: it mounts (appears on screen), updates (renders again with new props or state) and unmounts (is removed). Older React classes had methods for each (componentDidMount, componentDidUpdate, componentWillUnmount). It's tempting to map effects onto these, but it leads to bugs.

  2. 2

    An effect's lifecycle is simpler: start synchronising (the setup) and stop synchronising (the cleanup). It may do this many times while the component stays mounted. Write each effect from its own point of view: "how do I start syncing with this room on this server, and how do I stop?" Never "what happens on mount?" If you write setup and cleanup correctly for one set of values, React handles mount, update and unmount for free.

  3. 3

    Reactive values are props, state and everything calculated from them inside the component (Topic 7.2). They can change on any render, so an effect that reads them must list them; whenever one changes, React re-synchronises: it runs the old cleanup (stop syncing with the old values) and the setup again (start with the new ones). Values outside the component, like a constant DEFAULT_PORT or an imported function, can't change, so they aren't dependencies.

  4. 4

    Mutable values that React doesn't track, such as location.pathname or ref.current, are not reactive: changing them doesn't trigger a render, so React can't re-sync when they change. Don't put them in the dependency array (the linter warns); subscribe to them properly instead (useSyncExternalStore, Phase 15).

  5. 5

    One effect per concern. Each effect should represent one independent synchronisation process. If an effect does two unrelated jobs (logging a visit and connecting to a server), a change in a value that only one job needs re-runs both. Split them: each effect gets just the dependencies it reads, and you can delete one without touching the other. The test: if removing one job leaves the other's code untouched, they were separate concerns.

  6. 6

    The payoff of this mindset: you stop writing "if it's the first time" logic, and every effect handles prop changes correctly. Bugs such as "the chat still shows the old room after I switch" disappear, because the effect re-syncs automatically when roomId changes.

Explain it without notes

01

How is an effect's lifecycle different from a component's lifecycle?

02

What makes a value reactive? Give examples of reactive and non-reactive values.

03

Why should you split an effect that does two unrelated things?

04

Why is thinking "on mount" dangerous when writing effects?

Practice

01

A Weather component receives city and has units state ("C" or "F"). It subscribes with const off = weatherFeed.subscribe(city, units, setData). Write the effect.

02

Split this effect: useEffect(() => { document.title = name; const id = setInterval(poll, delay); return () => clearInterval(id); }, [name, delay]);

Trade-offs

  • ↔

    Re-syncing on every dependency change is simple and correct; when re-syncing is expensive, reduce what the effect reads (Topic 7.9) rather than skipping dependencies.

  • ↔

    More, smaller effects are easier to reason about but mean more setup/cleanup pairs; the clarity is nearly always worth it.

Done when you can

  • I describe effects as start syncing / stop syncing.

  • I can list an effect's reactive values without the linter.

  • I can predict the logs when a prop or state used by an effect changes.

  • I split effects that do unrelated jobs.

  • I don't plan effect logic around "on mount".