Effects are for synchronization
useEffect synchronizes React with an external system — subscriptions, browser APIs, non-React widgets, connections — and cleans them up; it is not a react-to-state hook for computing derived data, which is the source of most effect bugs.
You know what useEffect does — it runs after render, re-runs when its dependencies change, and cleans up on unmount. But “what it does” is not “what it’s for”, and the gap between those two is where most effect bugs are born. Engineers learn the mechanics and conclude that useEffect is the place to put “code that should run when something changes”. That conclusion is wrong, and it scales into a codebase full of cascading effects no one can follow.
useEffect has exactly one job: keep React synchronized with a system that lives outside React. A WebSocket, the browser’s document.title, a <canvas> chart library, a geolocation watcher. If there is no external system in the picture, the odds are overwhelming that you do not need an effect at all.
After this lesson you can state that an effect’s purpose is to synchronize React with an external system, not to react to state changes; write a correct effect that subscribes to or sets up an external system and tears it down in a cleanup function; recognize the dominant failure mode — using an effect to compute derived data from props or state; and apply the senior heuristic: name the external system the effect synchronizes with, and if you can’t, delete the effect.
An effect is an escape hatch out of React’s render flow — it exists to reach a system React doesn’t control. Rendering is pure: given props and state, you describe UI, and React owns the rest. But the real world has things React knows nothing about — network sockets, document.title, localStorage, an imperative chart instance, the IntersectionObserver API. An effect is the sanctioned way to step outside render, touch that external thing, and keep it in step with your component’s current state.
// the canonical shape: set up the external system, return its teardown
useEffect(() => {
const connection = createConnection(roomId); // step outside React
connection.connect();
return () => connection.disconnect(); // tear down on cleanup
}, [roomId]); // re-sync when roomId changesRead that dependency array as “re-synchronize whenever roomId changes”, not “run this when roomId changes”. The framing matters: an effect synchronizes; it doesn’t react.
A correct effect always describes a two-way contract: set up, and tear down. The cleanup function isn’t optional politeness — it’s half the pattern. React may run your effect, then later re-run it (deps changed) or unmount the component, and every setup must have a matching teardown or you leak. A subscription with no unsubscribe, a setInterval with no clearInterval, an event listener never removed — each one accumulates across renders and remounts.
function ChatRoom({ roomId }: { roomId: string }) {
useEffect(() => {
const socket = new WebSocket(`wss://chat.example.com/${roomId}`);
const onMessage = (e: MessageEvent) => console.log(e.data);
socket.addEventListener("message", onMessage);
return () => {
socket.removeEventListener("message", onMessage);
socket.close(); // teardown mirrors setup exactly
};
}, [roomId]);
return <div>Room: {roomId}</div>;
}In React 19’s Strict Mode, the effect is intentionally run setup→cleanup→setup in development to surface a missing teardown immediately. If your effect breaks under that double-invoke, the cleanup is wrong — that’s the test doing its job, not a bug to suppress.
Synchronizing with a browser API is the same pattern — even when it looks like “just set a value”. Setting document.title from your component’s state is a synchronization: document is an external system, and you’re keeping it in step with React state. It belongs in an effect, and (because the title persists) it ideally restores on cleanup.
function PageTitle({ unread }: { unread: number }) {
useEffect(() => {
const previous = document.title;
document.title = unread > 0 ? `(${unread}) Inbox` : "Inbox";
return () => { document.title = previous; }; // restore on unmount
}, [unread]);
return null;
}The tell that this is a real effect: the target (document.title) is not React state, not a prop, not JSX. You are reaching out of the render tree to mutate the world. That’s the whole license for an effect to exist.
The dominant failure mode: an effect used to compute derived data from props or state. This is the misuse that produces most “my effect bugs out” reports. The reasoning feels right — “when items or query changes, recompute filtered” — so people write an effect that sets a second state variable. But there is no external system here. filtered is a pure function of items and query; computing it during render is correct, synchronous, and bug-free. The effect version introduces an extra render, a second source of truth, and a window where the screen shows stale data.
// WRONG: no external system — this is derived data wearing an effect costume
const [filtered, setFiltered] = useState<Item[]>([]);
useEffect(() => {
setFiltered(items.filter((i) => i.name.includes(query)));
}, [items, query]); // extra render + can show stale data
// RIGHT: compute during render; no effect, no second state, never stale
const filtered = items.filter((i) => i.name.includes(query));
// (wrap in useMemo only if the filter is genuinely expensive)The heuristic: before writing an effect, name the external system it synchronizes with. “It synchronizes with… filtered” is not an external system — it’s React state, and the effect must be deleted.
A live “online status” badge — first the effect-as-reaction trap, then the synchronization done right. The component must show whether the user is connected, where connectivity is reported by the browser’s navigator.onLine plus online/offline events — an external system.
A common first cut mixes a real subscription with a derived-data effect:
function StatusBadge({ user }: { user: { name: string } }) {
const [isOnline, setIsOnline] = useState(navigator.onLine);
const [label, setLabel] = useState("");
// effect #1: a REAL synchronization with the browser's connectivity system
useEffect(() => {
const on = () => setIsOnline(true);
const off = () => setIsOnline(false);
window.addEventListener("online", on);
window.addEventListener("offline", off);
return () => {
window.removeEventListener("online", on);
window.removeEventListener("offline", off);
};
}, []);
// effect #2: WRONG — `label` is derived from isOnline + user; no external system
useEffect(() => {
setLabel(`${user.name} is ${isOnline ? "online" : "offline"}`);
}, [isOnline, user.name]);
return <span>{label}</span>;
}Effect #1 is exemplary: it subscribes to an external system and cleans up. Effect #2 is the failure mode — label is purely a function of isOnline and user.name, so the effect adds a render and a stale window for nothing. The fix keeps the one real synchronization and deletes the fake one:
function StatusBadge({ user }: { user: { name: string } }) {
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
const on = () => setIsOnline(true);
const off = () => setIsOnline(false);
window.addEventListener("online", on);
window.addEventListener("offline", off);
return () => {
window.removeEventListener("online", on);
window.removeEventListener("offline", off);
};
}, []);
const label = `${user.name} is ${isOnline ? "online" : "offline"}`; // derived in render
return <span>{label}</span>;
}One effect, because there is exactly one external system. The badge text is computed during render. (In a real app you’d reach for useSyncExternalStore to subscribe to navigator.onLine — that’s the next refinement of this exact pattern, and it exists precisely because subscribing to an external store is so common.)
▸Why this works
Why is “don’t use effects for derived data” stated so absolutely, when an effect can technically do it? Because each effect-as-reaction is a node in a hidden dependency graph: effect A sets state that triggers effect B that sets state that triggers effect C. Every link adds a render pass, a tick of stale UI, and a chance to forget a dependency. React renders are cheap and synchronous; effects are asynchronous and ordered after paint. Computing derived values in render keeps everything in one synchronous pass with a single source of truth. The discipline isn’t dogma — it’s collapsing a fragile multi-pass cascade back into one pass.
▸Common mistake
The trickiest cases hide behind real external systems. “I fetch in an effect, then in another effect I transform the response into display state.” The fetch is a legitimate synchronization with the network; the transform effect is the same derived-data mistake — transform during render, or in the fetch’s own .then. Naming the system per effect catches this: effect one synchronizes with the server; effect two synchronizes with nothing, so it’s not an effect. One effect, one external system. If an effect’s only job is to write React state from other React state, it’s derived data, full stop.
A reviewer sees an effect whose body is `setSummary(\`${items.length} items, ${selected.size} selected\`)` with deps `[items, selected]`. Both `items` and `selected` are React state. What's the senior verdict?
useEffect is an escape hatch to synchronize React with an external system — a subscription, a browser API like document.title, a non-React widget, a network connection — and a correct effect always has a matching cleanup that tears down exactly what its setup created. Read the dependency array as “re-synchronize when these change”, not “react when these change”. The dominant failure mode, and the source of most effect bugs, is using an effect as a general react-to-state mechanism to compute derived data: that value is a pure function of props and state, so it belongs in render (or useMemo if expensive), never in a second state variable written by an effect — which only buys an extra render, a second source of truth, and stale UI. The one heuristic that prevents all of it: before writing an effect, name the external system it synchronizes with; if you can’t name one, you don’t need the effect.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.