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 senior React judgment of when a 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. And four of those five effects are wrong.
They aren’t wrong because effects are bad. The last three lessons were spent learning to use them well. They’re wrong because each one reaches past a tool that fits the job better. A derived value belongs in render. An event belongs in a handler. Initial data belongs on the server. A parent notification belongs in the same handler that made the change. Each misplaced effect adds a wasted render, a window where the data is stale, and a fresh chance at an infinite loop, and these are all costs you pay for nothing when a closer tool was already available.
Lessons 1 through 3 taught how effects work: Strict Mode as the messenger, the setup-and-cleanup lifecycle, the non-reactive seam. This lesson teaches the judgment that comes before any of that, which is whether to write one at all. You’ll leave with two things. The first is a reusable audit, five questions you run before writing any effect. The second is a catalog of the named anti-patterns, so you recognize each one the instant you see it and know the tool that should have been there instead.
One idea holds the whole 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. It’s five questions, and the order matters: you ask them top to bottom and stop at the first “yes,” because the earlier questions catch the cheaper, more common mistakes. Four of the five send you somewhere other than an effect. Only the last one ends with useEffect in your hands.
useState.use() for a simple read. Caching, invalidation, and polling are a solved problem, and the solution isn’t an effect.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 below, picking the branch that matches a situation you’re actually modeling and following it to the verdict. The order of the questions matters more than the individual leaves, because at a real keyboard you’ll replay this exact 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 home concept is “Derive in render, do not mirror into state”.)
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 already in the first paint: no client effect, no spinner, fully server-renderable. (The App Router owns this path; for now, just recognize it.)
Caching, refetching, polling, and optimistic updates are a solved problem with a purpose-built tool.
An effect would hand-roll all of it, badly.
(TanStack Query comes later in the course; use() is the lesson after next.)
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, then the value already exists and already flows. There’s nothing to synchronize.
Below is the same audit as a table, for when you’ve internalized the order and just need the question-to-tool mapping in front of you.
| 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 |
With the audit in place, here is the catalog. Each entry is a shape, a recognizable pattern 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 is a component that 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 is stored in state and an effect re-syncs it whenever items changes. It 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’s supposed to sum.
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 keep in sync. The useState and the useEffect both disappear, and so does the entire 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 legitimate effect synchronizes with an external system and tears it down on the way out, while this one just copies a value React already has into a second copy React now has to maintain. The home concept here is deriving state in render, which you already know. This lesson is about catching the moment you almost store it instead.
The tooling now catches this for you, 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 almost never to silence it. The fix is to derive the value in render. We’ll cover wiring up that lint config in a later lesson on the rules of hooks. For now, just know the rule exists and points at the same smell.
There is one honest caveat. “Compute it in render” sometimes 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 the computation is genuinely heavy, the answer is still to compute it in render: you let the React Compiler memoize it for you, or reach for useMemo where you need manual control. The fix for an expensive derived value is never an effect plus a piece of state. We’ll get to the mechanics of memoization in the next chapter. The point here is only that “it’s expensive” doesn’t promote a derived value to an effect.
This is the one refactor worth doing rather than just reading, because it’s the single most common fix you’ll make. The component below works, but it’s carrying 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. No state to hold, no effect to sync, no stale frame: the total is correct the instant the component renders. Both the useState and the useEffect import disappear with it.
The next shape watches a prop and resets some state when it changes. An editable form is the classic example: it keeps a draft in local state so the user can type, and when the parent selects a different record, the form needs to reload the draft from the new record.
The reflex is to reach for an effect. That works, but it’s still 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 every time record changes and overwrites the draft. But there’s a beat where it’s wrong: when record changes, the component first renders with the old draft, the effect fires, and then it renders again with the new one. The user sees the previous record’s text flash for a frame.
// 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 fresh state initialized straight from the new record. No effect, no stale flash. The reset isn’t something you do on a prop change; it’s a consequence of the component being a different instance.
The lever here is component identity . React identifies each instance by where it sits in the tree and its key. Give a different key and you’ve told React “this is a different thing,” so it discards the old instance, state and all, and mounts a new one. That’s the cleanest possible reset: you don’t synchronize the state back to the prop, you replace the component whose state it was. The key-as-identity mechanism is the home concept from “Remounting with key”, and here you’re just recognizing reset-on-prop-change as a job it does for free.
There’s a narrow exception, and it’s worth knowing precisely because it’s narrow. Sometimes you don’t want to reset the whole form. You want to keep most of the user’s edits but reset one field when a prop changes. You can’t use key for that, because 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 }))} /> );};Immediately record the new id, so this branch runs exactly once per record change, not on every render after it.
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 the field we want, 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 is the only place React sanctions calling a setter during render, and it does so on purpose: React notices the state changed mid-render, throws away the in-progress output, and immediately re-runs the component with the updated state, all before anything reaches the screen. No flash, no effect, no extra paint. But it’s the rare case, not the common one.
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 that runs when the user acts, but instead it’s parked in an effect that watches the state the action happened to change.
The tell is a question you can ask out loud: 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: every one of those 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, which shows 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 to track, no effect watching it, no phantom toast on remount. The trigger and the work are back together.
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 to keep event work out of effects, and you already met it in the first lesson of this chapter. Strict Mode double-invokes effect setups in development, but it does not double-invoke event handlers. Park a “fire once” action in an effect and Strict Mode will fire it twice, giving you a double toast or a double analytics event, which surfaces the misplacement immediately. The handler version doesn’t have this problem at all.
These two shapes share one root cause: using effects to push a single logical change through multiple pieces of state, or across the boundary to a parent. Each hop is a render, and each render is a chance to loop.
A chain of effects works like dominoes. State A changes, an effect sets B, and another effect watches B and sets C. Picking a country, then resetting the region, then recomputing the tax rate is one logical transition, but spread across three effects it becomes three renders. The renders in between show a half-updated UI, where the country is new but the region is still the old one. The fix is to stop chaining: compute B and C in the same handler that sets A, so the whole transition happens at once. When the transition coordinates enough state that the handler gets unwieldy, that’s the signal to model it atomically with useReducer, where one dispatch produces one consistent next state. The heuristic is blunt and worth keeping: effects feeding effects feeding effects means you wanted a reducer.
Notifying the parent is the more common real bug, so it’s the one we’ll see 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 render of lag, no loop. If the parent turns out to be the real owner of this value, go further and lift the state up to it entirely, so there’s nothing to notify.
If it turns out 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, pass it down as a prop, and the synchronization problem disappears because there’s only one copy. That’s the home concept from “The four homes for state”, and the skill here is recognizing “I’m notifying the parent through an effect” as a sign the state lives in the wrong place.
There’s a related smell in the same family: a child that copies a prop or context value into local state through an effect, then reads its own copy. The copy is always one render stale, and the fix is one sentence: 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 a stale copy and a synchronization chore, and nothing else.
This is the big one: historically the single most common reason anyone wrote useEffect, and the one most thoroughly retired by 2026. You’ll see this shape everywhere in code written before 2023, and in a lot of AI-generated code that learned from it. The shape is unmistakable:
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, then 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. And the quietest, most expensive problem is that this code does not run during server-side rendering . Effects only run in the browser, so when the server renders this page, data is null and the HTML ships with a spinner in it. The page can’t be server-rendered with its actual content, which is bad for the first paint the user sees and bad for anything that reads your HTML without running your JavaScript.
So 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, and the data present in the very 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 genuinely must be a Client Component. The Server Component parent starts the fetch and passes the unawaited promise down, and the Client Component reads it with use() under a <Suspense> boundary. The lesson after next covers this in full.
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.)
The rule to walk away with is that a useEffect that fetches is a code-review red flag in 2026. It isn’t always wrong, though. There’s a genuinely residual sliver where it survives: an SDK that only hands you a callback with no awaitable surface, or a non-cacheable client-only POST fired mid-interaction. Even then it’s rare, and even then the way to do it safely is the race-condition mechanics you learned two lessons ago, the abort-on-resync and ignore-flag patterns. But “fetch the page’s data in an effect on mount” is not that residual case. It’s the anti-pattern, and you reshape it.
Here is a quick check on the shape, since this is the one you’ll meet most in the wild.
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, but not all of them, and the goal here isn’t to leave you avoiding useEffect on sight. The fifth question’s case is real, and an experienced engineer reaches for it without hesitation when the job actually calls for it. Every legitimate case is the same thing in different forms: synchronization 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, and you can 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, or 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> open and closed, or a <details>, from React state. The element’s open or closed status is the external state you’re syncing to.
Non-React script init
A third-party script that must be set up against the live DOM. The effect runs the setup once the node exists; cleanup tears it down.
The through-line is the test from the start of the lesson, now sharpened into a rule: every one of these returns a cleanup that tears down exactly what it set up, whether that’s a connection to close, a widget to destroy, or a listener to remove. If your effect has no external system and an empty cleanup, it doesn’t belong in this section. It’s one of the catalog patterns above in disguise.
One legitimate “store a value in an effect” pattern is worth naming, as the exception that proves the rule. Writing the previous value of a prop to a ref, the building block of a usePrevious helper, looks like the mirror smell, but it isn’t: the ref is a tiny external store, and the effect is genuinely synchronizing it. That’s why it’s the one sanctioned case of stashing a value in an effect. You’ll see it packaged as a reusable usePrevious custom hook when we get to custom hooks in the next chapter. For now, just file it as a real synchronization, not a violation.
Everything above compresses into a reflex you can run in your head against any effect, whether it’s your own, 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. That two-question filter is the same audit from the start of the lesson, run in review instead of while writing. You don’t need to remember all eight smells by name. You need to ask those two questions and let a blank answer tell you something’s wrong.
Why does this matter enough to spend a whole lesson on it? Because an effect is never free. Each one adds a render, a stale-closure surface, a possible loop, and a line of maintenance liability that someone has to reason about every time they touch the file. One misplaced effect costs little on its own. But a codebase with dozens of effects scattered across hundreds of components almost certainly has an ordering bug, a race, or a loop somewhere in it, and the only question is whether you’ve found it yet. The discipline that marks an experienced engineer here isn’t “use effects well.” It’s to reach for useEffect only when nothing else fits, and the audit is how you know.
To make the reflex muscle memory, start with a fast sort. Each scenario below belongs in exactly one bucket, so drop it where the audit sends it.
Sort each situation into the tool the audit points you to. Drag each item into the bucket it belongs to, then press Check.
Now for the real thing. Below is a small pull request, two files, from a teammate. It compiles and the feature works in a quick click-through. It also carries the catalog’s smells. Read it the way you’d review it for real: click any line that’s wrong and leave a comment naming the defect and the reshape. Run 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 of the three 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 wearing the disguise of a real effect. 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.
That review is the reflex in miniature. The skill isn’t memorizing eight smells by name. It’s running the two questions on any effect you meet and trusting a blank answer to expose a misplaced one.
This lesson is built directly on the React docs’ own audit. “You Might Not Need an Effect” is the canonical long-form catalog, worth reading as the reference companion to this lesson, and “Synchronizing with Effects” is its counterpart, the page that maps out the residual surface where effects genuinely belong.
If you’d rather watch the whole catalog worked through in code, the video below is the closest companion 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.