Real-time connections
A WebSocket, an EventSource/SSE stream, a BroadcastChannel. Setup opens the connection; cleanup closes it. (The chat-room example from the last two lessons lives here.)
The React judgment of when useEffect is the wrong tool, and which closer-fitting tool to reach for instead.
A teammate opens a pull request: one component, five useEffects. The first keeps a totalPrice in sync with the cart items. The second reloads a form draft whenever the selected record changes. The third fires an analytics event after a save lands. The fourth fetches the page’s initial data on mount. The fifth tells the parent that a value changed. It compiles, it runs, the demo looks fine. Four of the five are wrong.
They aren’t wrong because effects are bad. They’re wrong because each one reaches past a tool that fits better. A derived value belongs in render, an event belongs in a handler, initial data belongs on the server, and a parent notification belongs in the handler that made the change. Every misplaced effect buys you a wasted render, a window where the data is stale, and a fresh chance at an infinite loop, all for nothing when a closer tool was already there.
One idea holds the lesson together: an effect synchronizes React with a system React doesn’t own, and if you can’t name that system, it isn’t an effect. A WebSocket, a chart widget, and the browser’s matchMedia are systems React doesn’t own. A sum of cart items is not. Hold that test in your head and most of the catalog becomes obvious.
Before you type useEffect, run an audit: five questions, asked top to bottom, stopping at the first “yes.” The order matters, because the earlier questions catch the more common mistakes. Four of the five send you somewhere other than an effect; only the last leaves useEffect in your hands.
useState.useEffect: a subscription, a third-party widget, or a browser API, something with a lifecycle outside React that you set up and tear down.Questions one through four are the same realization in four forms: the effect you were about to write is a symptom, a sign you reached past a closer tool. Only the fifth is the real thing. Walk the tree, picking the branch that matches what you’re actually modeling; at a real keyboard you’ll replay this walk in your head.
A value you can derive from props or state isn’t state, it’s an expression.
Put it in the render body and let it recompute every render: no useState, no effect, nothing to keep in sync.
The work happens because the user did something specific, so put it where that action is handled. A handler reads the latest values directly, with no state to watch and no render of lag.
Fetch it on the server, before render, so the data is in the first paint: no client effect, no spinner.
Caching, refetching, polling, and optimistic updates are a solved problem with a purpose-built tool. An effect would hand-roll all of it, badly.
A real external system with a lifecycle React doesn’t manage. Setup synchronizes with it, cleanup tears it down. This is the one branch where the effect is the right answer.
If it isn’t derived, isn’t an event, isn’t data to load, isn’t cached state, and there’s no external system, the value already exists and already flows. There’s nothing to synchronize.
The same audit as a table, for when you know the order and just need the question-to-tool mapping:
| The question | The tool |
|---|---|
| Can I derive it from props/state I have? | Compute it in render |
| Did a specific user interaction trigger it? | Event handler |
| Is it the page’s initial data? | Server Component / route loader |
| Is it cached server state (refetch, poll, optimistic)? | TanStack Query, or use() |
| Am I syncing with an external system React doesn’t own? | useEffect, the residual case |
Now the catalog. Each entry is a recognizable shape of useState and useEffect you can spot at a glance, paired with the tool that should have been there instead. We’ll walk them in audit order, so you always know which question the code failed.
This is the most common misuse and the highest-value fix, so we start here. The shape: a component holds a value in useState, then runs an effect to keep it in sync with the props it’s computed from.
Here’s a cart. It receives items as a prop and needs to show the total price.
export const CartSummary = ({ items }: { items: CartItem[] }) => { const [total, setTotal] = useState(0); useEffect(() => { setTotal(items.reduce((sum, item) => sum + item.price, 0)); }, [items]);
return <p>Total: {formatMoney(total)}</p>;};This is the textbook anti-pattern. total lives in state, and an effect re-syncs it whenever items changes. That costs two renders: React renders once with the stale total, the effect fires, and setTotal schedules a second render. For one frame the displayed total lags the items it sums.
export const CartSummary = ({ items }: { items: CartItem[] }) => { const total = items.reduce((sum, item) => sum + item.price, 0);
return <p>Total: {formatMoney(total)}</p>;};total isn’t state, it’s an expression. Compute it in the render body and it’s always correct, in a single render, with nothing to sync. The useState and the useEffect both disappear, and so does the class of bug they introduced.
The shape is worth memorizing: useEffect(() => setX(somethingDerivedFromProps)), with no cleanup, is this anti-pattern every time. The missing cleanup is the tell. A real effect synchronizes with an external system and tears it down on the way out; this one just copies a value React already has into a second copy React now has to maintain. You already know how to derive state in render. The skill this lesson teaches is catching the moment you almost store it instead.
The tooling catches it too. The modern eslint-plugin-react-hooks ships a set-state-in-effect rule that flags exactly this shape: a state setter called synchronously inside an effect. When it fires, the fix is to derive the value in render, not to silence the rule. We wire up that lint config in a later lesson; for now, just know it points at the same smell.
One honest caveat: “compute it in render” draws the objection that the calculation is expensive, so won’t running it every render be slow? If the reduce is trivial, no, and you shouldn’t optimize it. If it’s genuinely heavy, the answer is still to compute it in render and let the React Compiler memoize it, or reach for useMemo where you need manual control. We cover the mechanics in the next chapter; the point here is that “it’s expensive” doesn’t promote a derived value to an effect.
This is the one refactor worth doing rather than reading, since it’s the fix you’ll reach for most. The component below works, but it carries the smell. Strip it down.
This CartSummary holds totalPrice in useState and syncs it with an effect — the derived-state anti-pattern. Remove both the useState and the useEffect, and derive totalPrice directly in the render body so the component produces the right total in a single render. The total should equal the sum of every item's price.
export const CartSummary = ({ items }) => { const totalPrice = items.reduce((sum, item) => sum + item.price, 0);
return <p>Total: ${totalPrice}</p>;};totalPrice is now an expression, evaluated every render. The total is correct the instant the component renders, with no state to hold and no effect to sync. Both the useState and the useEffect import disappear with it.
This shape resets local state whenever a prop changes. An editable form is the classic case: it keeps a draft in local state so the user can type, and when the parent selects a different record, it has to reload the draft from the new record.
The reflex is an effect. That works, but it’s the wrong shape.
export const EditForm = ({ record }: { record: EditableRecord }) => { const [draft, setDraft] = useState(record); useEffect(() => { setDraft(record); }, [record]);
return <textarea value={draft.body} onChange={(e) => setDraft({ ...draft, body: e.target.value })} />;};The effect re-runs whenever record changes and overwrites the draft. But there’s a beat where it’s wrong: the component renders once with the old draft, the effect fires, then it renders again with the new one. For that frame the user sees the previous record’s text.
// Parent — give the form a fresh identity per record:<EditForm key={record.id} record={record} />;
// EditForm — just initialize from the prop, no effect:export const EditForm = ({ record }: { record: EditableRecord }) => { const [draft, setDraft] = useState(record);
return <textarea value={draft.body} onChange={(e) => setDraft({ ...draft, body: e.target.value })} />;};A changed key makes React throw the old instance away and mount a fresh one, with state initialized straight from the new record. No effect, no stale flash. You don’t do the reset on a prop change; it falls out of the component being a different instance.
The lever here is component identity . Give a different key and React discards the old instance, state and all, and mounts a new one. You don’t synchronize state back to the prop; you replace the component whose state it was. This is the same mechanism from “Remounting with key”, reused for reset-on-prop-change.
There’s one narrow exception. Sometimes you want to keep most of the user’s edits and reset only one field when a prop changes. key can’t do that — remounting would throw away the edits you meant to keep. The sanctioned pattern is to adjust that one piece of state during render, gated by a comparison against the previous value.
export const EditForm = ({ record }: { record: EditableRecord }) => { const [draft, setDraft] = useState(record); const [lastRecordId, setLastRecordId] = useState(record.id);
if (record.id !== lastRecordId) { setLastRecordId(record.id); setDraft((current) => ({ ...current, body: record.body })); }
return ( <textarea value={draft.body} onChange={(e) => setDraft((d) => ({ ...d, body: e.target.value }))} /> );};Store the previous record.id in state alongside the draft. This is the value we compare against to detect a change.
export const EditForm = ({ record }: { record: EditableRecord }) => { const [draft, setDraft] = useState(record); const [lastRecordId, setLastRecordId] = useState(record.id);
if (record.id !== lastRecordId) { setLastRecordId(record.id); setDraft((current) => ({ ...current, body: record.body })); }
return ( <textarea value={draft.body} onChange={(e) => setDraft((d) => ({ ...d, body: e.target.value }))} /> );};On every render, compare the current record.id to the stored one. When they differ, a new record just arrived, and we’re still mid-render, before React has committed anything to the screen.
export const EditForm = ({ record }: { record: EditableRecord }) => { const [draft, setDraft] = useState(record); const [lastRecordId, setLastRecordId] = useState(record.id);
if (record.id !== lastRecordId) { setLastRecordId(record.id); setDraft((current) => ({ ...current, body: record.body })); }
return ( <textarea value={draft.body} onChange={(e) => setDraft((d) => ({ ...d, body: e.target.value }))} /> );};Record the new id immediately, so this branch runs once per record change rather than on every later render.
export const EditForm = ({ record }: { record: EditableRecord }) => { const [draft, setDraft] = useState(record); const [lastRecordId, setLastRecordId] = useState(record.id);
if (record.id !== lastRecordId) { setLastRecordId(record.id); setDraft((current) => ({ ...current, body: record.body })); }
return ( <textarea value={draft.body} onChange={(e) => setDraft((d) => ({ ...d, body: e.target.value }))} /> );};Reset only body and keep the rest of the draft. Calling a setter during render makes React discard this render and re-run the component right away with the new state, before it paints. The user never sees the in-between.
This one is subtler, because the trigger really is a user action. The bug is where the code lives. The work belongs in the handler, but it’s parked in an effect that watches the state the action happened to change.
The tell is a question: should this happen because the user did X, or because state Y changed? A toast after a save, a redirect after a submit, an analytics ping on a click: each happens because the user did something specific. Watching state to fire that work causes two problems. The effect runs on every path that sets the state, including ones you never intended, and it always runs one render late.
const [isSaved, setIsSaved] = useState(false);useEffect(() => { if (isSaved) { showToast('Invoice saved'); }}, [isSaved]);
const handleSave = async () => { await saveInvoice(draft); setIsSaved(true);};The toast is divorced from the action that should cause it. It fires on any path that flips isSaved to true, including a remount where isSaved arrives already true, showing a phantom “saved” toast the user never earned. And it lands a render after the save, not with it.
const handleSave = async () => { await saveInvoice(draft); showToast('Invoice saved');};The toast lives where the save happens. It fires exactly once, on exactly the path the user took, the moment the save resolves. No isSaved state, no effect watching it, no phantom toast on remount.
Here’s the boundary rule: effects run because the component is displayed and needs to stay synchronized, while handler work runs because the user did something specific. If the cause is a user action, the code goes in the handler. There’s a second reason, from the first lesson of this chapter: Strict Mode double-invokes effect setups in development, but not event handlers. Park a “fire once” action in an effect and Strict Mode fires it twice, giving you a double toast or a double analytics event that surfaces the misplacement immediately.
These two shapes share one root cause: using effects to push a single logical change through several pieces of state, or across the boundary into a parent. Each hop is a render, and each render is a chance to loop.
A chain of effects falls like dominoes. State A changes, an effect sets B, and another effect watches B and sets C. Picking a country, resetting the region, and recomputing the tax rate is one logical transition, but split across three effects it becomes three renders, and the renders in between show a half-updated UI: new country, old region. The fix is to stop chaining and compute B and C in the same handler that sets A, so the whole transition happens at once. When a transition coordinates enough state that the handler gets unwieldy, model it atomically with useReducer, where one dispatch produces one consistent next state. Effects feeding effects feeding effects means you wanted a reducer.
Notifying the parent is the more common bug, so we’ll see it in code. A child holds some state and wants to tell its parent when that state changes, so it reaches for an effect that calls the parent’s callback.
const [value, setValue] = useState('');useEffect(() => { onChange(value);}, [value]);
return <input value={value} onChange={(e) => setValue(e.target.value)} />;The parent hears about the change one render too late. The child re-renders with the new value, and then the effect fires and notifies. Worse, if the parent re-renders the child in response, the effect can fire again, and you’re one careless line away from an update loop.
const [value, setValue] = useState('');
const handleChange = (next: string) => { setValue(next); onChange(next);};
return <input value={value} onChange={(e) => handleChange(e.target.value)} />;One handler updates the local state and notifies the parent in the same step: same value, same moment, no lag, no loop.
If the parent is the one that really owns this value, because both the child and a sibling need it, don’t notify at all. Lift the state up to the parent and pass it down as a prop: with one copy, there is nothing to synchronize. That’s the home concept from “Four homes for state”, and the skill is reading “I’m notifying the parent through an effect” as a sign the state lives in the wrong place.
A related smell in the same family: a child copies a prop or context value into local state through an effect, then reads its own copy, which is always one render stale. Read it, don’t mirror it. If the value comes down as a prop or out of context, read it directly in render; mirroring it into state buys you nothing but a stale copy and a chore to keep it in sync.
This is the big one: historically the most common reason anyone reached for useEffect, and the most thoroughly retired by 2026. You’ll see this shape everywhere in code written before 2023, and in AI-generated code that learned from it:
const [data, setData] = useState(null);useEffect(() => { fetch(`/api/invoices`) .then((response) => response.json()) .then(setData);}, []);
if (!data) return <Spinner />;Consider what this costs. It renders once empty and again when the data lands, so two renders minimum with a spinner in between. It hand-rolls loading state. It has no error handling, so a failed request leaves the spinner forever. It has no race-condition guard, so if the inputs change before the fetch resolves you can paint stale data. The quietest and most expensive problem is that this code never runs during server-side rendering : the server ships HTML with a spinner where the content should be, which hurts the first paint the user sees and anything that reads your HTML without running your JavaScript.
What replaces it? Work down this ladder and take the first rung that fits.
Server Component awaits the data directly, the 2026 default. Write const invoices = await listInvoices(); right in a Server Component: no effect, no client JavaScript for the fetch, the data present in the first paint and fully server-renderable. Reach past this only when you can’t. (The App Router owns this path; for now, just recognize it.)
use() a promise from a Server Component parent, when the consumer must be a Client Component. The parent starts the fetch and passes the unawaited promise down; the Client Component reads it with use() under a <Suspense> boundary. (Covered in the lesson after next.)
TanStack Query, when you need client-side caching across views, polling, invalidation, or optimistic updates. It’s a purpose-built server-state cache, not a hand-rolled effect. (Covered later in the course.)
So a useEffect that fetches is a code-review red flag in 2026 — though not always wrong. A residual sliver survives: an SDK that hands you only a callback with no awaitable surface, or a non-cacheable client-only POST fired mid-interaction. Even then it’s rare, and the safe way to do it is the race-condition mechanics from two lessons ago, the abort-on-resync and ignore-flag patterns. But fetching the page’s data in an effect on mount is not that case. It’s the anti-pattern, and you reshape it.
Here’s a quick check on the shape you’ll meet most often.
A Client Component renders a dashboard’s initial list of invoices like this:
const [invoices, setInvoices] = useState([]);useEffect(() => { fetch('/api/invoices').then((r) => r.json()).then(setInvoices);}, []);It’s the page’s initial data, read once on load, with no polling or caching needs. What’s the right reshape?
AbortController to the effect and abort it in the cleanup, so the fetch is race-safe.fetch in a useEffectEvent so it isn’t reactive and the effect’s deps can stay empty.fetch into a useMemo keyed on an empty array so it only runs once.AbortController only makes a still-misplaced effect race-safe; it doesn’t address that the data shouldn’t be effect-fetched at all. useEffectEvent is for a non-reactive read inside a legitimate effect, not for relocating a fetch. And useMemo memoizes a computed value — it must never run side effects, so fetching inside it is its own anti-pattern.Run the audit honestly and most “I need an effect” instincts dissolve. The ones that survive are real. Every legitimate case is the same thing in different forms: synchronizing with a system React doesn’t own, set up on the way in and torn down on the way out. The five categories below are the ones you’ll actually meet; read each as an answer to “what does this effect synchronize with?”
Real-time connections
A WebSocket, an EventSource/SSE stream, a BroadcastChannel. Setup opens the connection; cleanup closes it. (The chat-room example from the last two lessons lives here.)
Third-party widgets
A chart library, a map, Stripe Elements, a video player: anything that takes a DOM node. React renders the container, the effect instantiates the widget against the node, and cleanup destroys it.
Browser APIs React doesn't model
matchMedia, IntersectionObserver, ResizeObserver, a raw scroll or resize listener. Subscribe in setup, unsubscribe in cleanup.
Native element state
Driving a <dialog> or <details> open and closed from React state. The element’s open status is the external state you’re syncing to.
Non-React script init
A third-party script that must run against the live DOM. The effect sets it up once the node exists; cleanup tears it down.
Every one of these returns a cleanup that tears down exactly what it set up: a connection to close, a widget to destroy, a listener to remove. If your effect has no external system and an empty cleanup, it doesn’t belong here. It’s one of the catalog smells above in disguise.
One “store a value in an effect” pattern is the exception that proves the rule. Writing a prop’s previous value to a ref looks like the mirror smell, but the ref is a tiny external store, and the effect genuinely synchronizes it. You’ll meet this packaged as a reusable usePrevious hook in the next chapter. For now, file it as real synchronization, not a violation.
Every smell above reduces to one reflex you can run against any effect, your own or a teammate’s or an AI’s. Two questions:
If the honest answers are “none” and “nothing,” the effect is almost certainly one of the catalog’s anti-patterns, so reshape it. You don’t have to recall all eight smells by name; you have to ask those two questions and trust a blank answer to expose a misplaced effect.
An effect is never free. Each one adds a render, a stale-closure surface, a possible loop, and a line someone must reason about every time they touch the file. One misplaced effect costs little. But a codebase with dozens of effects scattered across hundreds of components almost certainly hides a race, a loop, or an ordering bug somewhere, and the only question is whether you’ve found it yet. The discipline isn’t “use effects well”; it’s reaching for useEffect only when nothing else fits, and the audit is how you know.
To make the reflex muscle memory, try it on a small two-file pull request from a teammate. It compiles and works in a quick click-through, and it carries the catalog’s smells. Review it as you would for real, running the two questions on every effect you find.
Review this PR the way you would for a teammate — flag anything that should be reshaped, not just bugs that crash. Run the two questions on every effect. Click any line to leave a review comment, then press Submit review.
'use client';
export const InvoiceList = () => { const [invoices, setInvoices] = useState([]); const [total, setTotal] = useState(0);
useEffect(() => { fetch('/api/invoices') .then((r) => r.json()) .then(setInvoices); }, []);
useEffect(() => { setTotal(invoices.reduce((sum, inv) => sum + inv.amount, 0)); }, [invoices]);
return ( <div> <p>Total outstanding: {formatMoney(total)}</p> <ul>{invoices.map((inv) => <li key={inv.id}>{inv.number}</li>)}</ul> </div> );};'use client';
export const SaveButton = ({ draft }: { draft: InvoiceDraft }) => { const [isSaved, setIsSaved] = useState(false);
useEffect(() => { if (isSaved) { showToast('Invoice saved'); } }, [isSaved]);
const handleSave = async () => { await saveInvoice(draft); setIsSaved(true); };
return <button onClick={handleSave}>Save</button>;};Run the two questions. What external system does this synchronize with? None — /api/invoices is the page’s initial data, not a system with a lifecycle. What does its cleanup tear down? Nothing. Blank answers both, so this is the fetch-on-mount anti-pattern, not a real effect.
The reshape: fetch it on the server. const invoices = await listInvoices(); in a Server Component, so the list is in the first paint with no client fetch, no spinner, and no [] empty-state render. This version also ships null/empty data in the server-rendered HTML, hand-rolls loading, and has no error handling or race guard — all of which the Server Component path gives you for free.
total isn’t state — it’s an expression. The useEffect(() => setTotal(...)) with no cleanup is the textbook derived-state smell: it copies a value React already has into a second copy React now has to maintain, and it lands one render late, so the total lags the list for a frame.
Delete both the useState for total and this effect, and compute it in the render body:
const total = invoices.reduce((sum, inv) => sum + inv.amount, 0);One render, always correct, nothing to keep in sync.
The toast happens because the user saved, so it belongs in the handler — not in an effect watching the state the save happened to set. As written it fires on every path that flips isSaved to true (including a remount where it arrives already true, a phantom toast nobody earned), it lands a render late, and Strict Mode double-invokes the effect in dev so you’d see it twice.
Move the call into the handler and drop isSaved entirely:
const handleSave = async () => { await saveInvoice(draft); showToast('Invoice saved');};Now it fires exactly once, on exactly the path the user took, the moment the save resolves.
Run the two questions on each effect here. None synchronizes with an external system, and none has a cleanup that tears anything down — that blank pair is the tell that all three are catalog anti-patterns disguised as real effects. The fetch belongs on the server, the total belongs in render, the toast belongs in the handler. If you flagged all three, you ran the exact reflex this lesson set out to build.
This lesson is built on the React docs’ own audit. “You Might Not Need an Effect” is the canonical long-form catalog, and “Synchronizing with Effects” is its counterpart, mapping the surface where effects genuinely belong. Read both as the reference companions to this lesson.
The canonical catalog this lesson is built on, with every anti-pattern and its reshape in full.
The counterpart page: when an effect genuinely is the answer, and how to write its setup and cleanup.
Dan Abramov's deep mental-model essay: why an effect is synchronization, not a lifecycle, and why each render captures its own values.
The lint rule that flags the derived-state smell automatically — a setter called synchronously inside an effect.