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.
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.
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].
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.
useEffect(() => {
logVisit(roomId);
}, [roomId]);
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => connection.disconnect();
}, [serverUrl, roomId]);Try it yourself
- 1
Make the port reactive
In the first example, move
const DEFAULT_PORT = 1234;insideChatRoom. 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
Switch back and forth
Click Switch room twice in the first example. Predict the four log lines before you click.
- 3
Change the room in the split demo
Give
Demoa room state and a button that changes it. Predict which effects ofSplitre-run (both) and which ofCombined(its only effect).
Code & diagrams
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
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:1234Changing 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
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 usBreak 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").
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
componentDidMountandcomponentDidUpdate(with a manualprevProps.roomId !== this.props.roomIdcheck) and teardown incomponentWillUnmount. 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
useSomethinghook 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
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
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
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_PORTor an imported function, can't change, so they aren't dependencies. - 4
Mutable values that React doesn't track, such as
location.pathnameorref.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
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
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
roomIdchanges.
Explain it without notes
How is an effect's lifecycle different from a component's lifecycle?
What makes a value reactive? Give examples of reactive and non-reactive values.
Why should you split an effect that does two unrelated things?
Why is thinking "on mount" dangerous when writing effects?
Practice
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.
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".