Effects & Lifecycle
An effect lets a component reach outside of React to talk to systems React does not control: the browser DOM, network requests, timers, subscriptions, or third-party widgets. The mental model that matters most is that useEffect is not a lifecycle hook in disguise. It is a tool for synchronization — for keeping some external system in step with the current props and state of your component. Once you internalize that, the old componentDidMount/componentDidUpdate/componentWillUnmount trio stops being the right map, and the dependency array starts to make sense.
Effects as synchronization, not lifecycle
A class component thinks in terms of moments in time: “the component just mounted, do X”; “something updated, do Y”; “we are about to unmount, undo X.” Effects think in terms of state: “while the component is showing, this external system should look like this.” React calls your effect whenever the values it depends on change, and runs your cleanup whenever those values are about to become stale. You describe the desired end state; React figures out when to apply and re-apply it.
This shift removes a whole category of bugs. In a class you had to remember to repeat setup logic in both componentDidMount and componentDidUpdate, then mirror it in componentWillUnmount. With effects, setup and cleanup live together, and re-synchronization is automatic.
import { useState, useEffect } from 'react';
function ChatRoom({ roomId }) {
const [status, setStatus] = useState('connecting');
useEffect(() => {
const connection = createConnection(roomId);
connection.on('open', () => setStatus('connected'));
connection.connect();
return () => connection.disconnect();
}, [roomId]);
return <p>Room {roomId}: {status}</p>;
}
When roomId changes, React does not “update” the existing connection. It runs the cleanup (disconnecting from the old room) and then runs the effect again (connecting to the new room). The component is always synchronized with the current roomId.
Mapping mount, update, and unmount
Every class lifecycle moment corresponds to a piece of the same effect, controlled entirely by the dependency array and the cleanup function.
| Class lifecycle | Effect equivalent |
|---|---|
componentDidMount | Effect body with [] deps |
componentDidUpdate | Effect body re-runs when a dep changes |
componentWillUnmount | The cleanup function returned by the effect |
| Mount + every update | Effect with no dependency array |
The three behaviors are not three separate APIs — they are three configurations of one API.
import { useState, useEffect } from 'react';
function WindowSize() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
// setup — runs on mount and after any re-sync
const onResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', onResize);
// cleanup — runs before re-sync and on unmount
return () => window.removeEventListener('resize', onResize);
}, []); // [] = synchronize once; never re-subscribe
return <p>Window width: {width}px</p>;
}
The empty dependency array says “this effect does not depend on any reactive value, so set it up once and tear it down at unmount.” That single declaration covers what used to be split across componentDidMount and componentWillUnmount.
How the dependency array drives re-runs
React compares each value in the dependency array against its value from the previous render using Object.is. If anything differs, it runs cleanup for the old run and then the effect for the new one. This is why the array must list every reactive value the effect reads — props, state, or anything derived from them.
useEffect(() => {
const id = setInterval(() => {
console.log(`Tick for user ${userId}`);
}, 1000);
return () => clearInterval(id);
}, [userId]);
Output:
Tick for user 42
Tick for user 42
// userId changes to 7 → old interval cleared, new one started
Tick for user 7
Tick for user 7
Do not “lie” to React by omitting a dependency to silence the linter. A stale dependency array means your effect keeps reading old values, producing bugs that are hard to trace. Fix the cause — move the value inside the effect, memoize it, or restructure — rather than trimming the array.
Timing and double-invocation in development
Effects run after the browser paints, so they never block the visual update. In development, React 18+ Strict Mode deliberately mounts, unmounts, and re-mounts each component, running your effect twice. This is not a bug — it surfaces effects whose cleanup is missing or incorrect. If your effect behaves correctly when run, cleaned up, and run again, it will behave correctly in production.
If a value of an effect must be measured before paint (for example, reading layout to avoid a flicker), reach for
useLayoutEffectinstead. It runs synchronously after DOM mutations but before the browser paints.
Best Practices
- Treat each effect as synchronizing one external system; split unrelated concerns into separate effects rather than cramming them together.
- Always return a cleanup function when your effect subscribes, connects, or schedules — anything that must be undone.
- List every reactive value the effect reads in the dependency array, and trust the
eslint-plugin-react-hookslinter instead of fighting it. - Prefer specific dependencies (
[roomId]) over an empty array when the effect genuinely depends on changing values. - Before reaching for an effect, ask whether the work belongs in an event handler or can be derived during render — many “effects” are not needed at all.
- Verify your cleanup is correct by relying on Strict Mode’s double-invocation in development.