Post 3 established a fact in passing that's worth picking back up: useEffect does not run during the SSR pass. Your client component executes on the server, produces markup, and its effects never fire.
That's not a limitation. It's a definition, and it's the clearest statement of what an effect actually is.
An effect is not "code that runs after render." It's a request to synchronise something outside React with something inside it. The server has no outside — no DOM, no window, no subscriptions, nothing to keep in sync. So there's nothing for an effect to do, and it doesn't run.
What you thought useEffect was for when you learned it - "componentDidMount but for hooks" is the common answer and it's the source of most of the trouble below.
Where the lifecycle idea came from
Class components had named lifecycle methods. componentDidMount, componentDidUpdate, componentWillUnmount. They were about the component's life: born, changed, died.
When hooks arrived, useEffect got explained as the replacement, usually with a translation table. Empty deps means mount. Some deps means update. The return function means unmount.
That table is why so much effect code is wrong. It teaches you to ask when does this run when the actual question is what is this keeping in sync.
The lifecycle framing survives because it's approximately true for simple cases and it fails silently for everything else.
The cleanup function is not "unmount"
The most consequential thing in the whole API, and the translation table gets it exactly wrong.
Cleanup does not run "when the component unmounts." It runs before every re-run of the effect, and also on unmount.
So an effect isn't a thing that happens once. It's a setup/cleanup pair that can happen many times, and each pair has to be self-consistent:
useEffect(() => {
const conn = createConnection(roomId)
conn.connect()
return () => conn.disconnect()
}, [roomId])Change roomId and React disconnects from the old room, then connects to the new one. It doesn't matter whether that's a mount, an update, or the fifteenth change — the pair holds.
Write the same thing with the lifecycle framing and you get a connection that's established on mount, never torn down when the room changes, and leaked.
The test for a correct effect: could it run twice in a row and leave the world in the same state? If not, the cleanup is wrong or missing.
Which is what StrictMode is for
In development, React deliberately runs setup, then cleanup, then setup again on mount.
People treat this as a nuisance and reach for a ref to suppress it. It's a test. It runs your setup/cleanup pair twice and shows you whether the pair is symmetric. If double-invoking breaks your effect, the effect was already broken — it just needed a second roomId change in production to show you.
Suppressing it hides the bug until a user finds it.
Effects you don't need
Most useEffect in real codebases isn't synchronising with anything external. Three patterns cover most of it.
Deriving a value. Post 8 covered this: if you can compute it, compute it.
// ✗ an extra render, and a window where they disagree
const [total, setTotal] = useState(0)
useEffect(() => { setTotal(items.reduce(...)) }, [items])
// ✓
const total = items.reduce(...)Responding to an event. If something should happen because the user did a thing, it belongs in the handler, not in an effect watching for the consequence.
// ✗ runs whenever cart changes, for any reason
useEffect(() => {
if (cart.length > 0) showToast('Added to cart')
}, [cart])
// ✓
function handleAdd(item) {
setCart([...cart, item])
showToast('Added to cart')
}The first version fires on page load if the cart was restored from storage. It fires when an item is removed. It has no idea why the cart changed, because effects can't — they see state, not causes.
Resetting state when a prop changes. There's a built-in mechanism and it isn't an effect:
// ✗ renders once with stale state, then again with reset state
useEffect(() => { setDraft('') }, [postId])
// ✓ a new key means a new component with fresh state
<Editor key={postId} post={post} />Post 2 covered why keys control identity. This is that mechanism used deliberately.
The deps array is a correctness contract
The lint rule that tells you to add a dependency is not a style preference and it is not about performance. It's telling you your effect reads a value it hasn't promised to stay in sync with.
Remove a dep to "stop it running so much" and you've built a stale closure — the effect keeps a snapshot of a value from a render that's long gone, and it will read that snapshot forever.
The right move is almost never removing a dep. It's changing what the effect closes over: move the value inside, use the updater form of setState, or split one effect into two that synchronise different things.
If the deps array is fighting you, the usual cause is that one effect is doing two unrelated jobs.
useEffectEvent, for reading without reacting
There's a genuine tension the deps array can't express: sometimes you want to read a value without reacting to it changing.
useEffect(() => {
const conn = createConnection(roomId)
conn.on('message', () => showNotification(theme))
return () => conn.disconnect()
}, [roomId, theme]) // reconnects when the theme changes. absurd.The effect reads theme, so the lint rule wants it in the deps, so changing the theme tears down your connection.
useEffectEvent separates the two. You extract the part that reads latest values into a function the effect can call without depending on it:
const onMessage = useEffectEvent(() => showNotification(theme))
useEffect(() => {
const conn = createConnection(roomId)
conn.on('message', onMessage)
return () => conn.disconnect()
}, [roomId])Reads the current theme every time, doesn't reconnect when it changes.
useLayoutEffect, and the actual sequence
To place useLayoutEffect you need the order of operations, which is worth having anyway.
- 1.
React render phase. Your components are called. React builds the new tree and diffs it. Nothing has touched the DOM.
- 2.
React commit phase. React applies the changes to the DOM. The DOM now says the new thing — but the screen doesn't yet.
- 3.
useLayoutEffectruns, synchronously. The browser is blocked here. You can read layout and write more DOM, and the user will never see the intermediate state. - 4.
The browser does its work. Recalculate styles, run layout, paint, composite. Now the screen changes.
- 5.
useEffectruns. After paint, asynchronously.
So the difference is one step: layout effects run before step 4, passive effects after.
That makes useLayoutEffect blocking by definition — you're holding the frame. A bad default, and the reason it isn't one. Use it only when the user would otherwise see a wrong frame: measuring an element and immediately repositioning something based on the measurement. A tooltip that renders at the wrong spot and then jumps is the canonical case.
It also doesn't run on the server, which produces a warning if you use it in server-rendered code. That warning is true and worth heeding — measuring requires layout, and post 3 explained why there is no layout on the server.
"I just want it to run on mount"
The most common effect in any codebase, and usually the most wrong:
useEffect(() => {
doTheThing(someValue)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])The empty array plus the disable comment is a specific admission: I know this effect reads things it doesn't list, and I want it to run once anyway.
The problem is that "once" isn't a thing React promises. Effects re-run when their dependencies change; an empty array says "this effect depends on nothing," and if that's false, you've written a lie the compiler can't catch. The effect keeps a snapshot of someValue from the first render, forever.
When you find yourself reaching for the disable comment, the useful question isn't "how do I make the linter stop." It's what am I actually trying to do, and the answer is usually one of four things:
"Fetch data when the page loads." RSC removed this. The server has the data before the component exists.
"Initialise something once per app." Analytics, a global client, a polyfill. This probably isn't component code at all — it's module-level code that runs on import, or something set up outside React entirely. Putting it in a component ties app-wide setup to one component's lifecycle for no reason.
"Set up a subscription." Then the deps are correct as written and you should let them be. If it depends on roomId, it genuinely needs to re-run when roomId changes — that's not the linter being annoying, that's the linter being right.
"React to a change, but only sometimes." This is the real case, and it's what useEffectEvent below is for.
There's also the ref-guard version:
const didRun = useRef(false)
useEffect(() => {
if (didRun.current) return
didRun.current = true
doTheThing()
}, [])This is usually written to suppress StrictMode's double-invoke. It doesn't fix anything — it hides the symptom that was telling you the setup/cleanup pair is asymmetric. If the effect genuinely can't be run twice safely, it needs a cleanup, not a guard.
What RSC deleted
Two of the most common effects in a pre-RSC codebase no longer have a reason to exist.
Fetching on mount. Post 8's example: useEffect fetching data and calling setState. The server does that now, before the component reaches the browser, without a loading state or a waterfall.
Syncing derived server data. All the effects that existed to keep a client copy of remote data current. Post 7 established that a mutation's response contains the new tree, so there's nothing to sync.
Strip those out and what's left is genuinely small.
Effects that are still correct
Not zero. The rule holds — synchronise with something outside React:
Browser APIs. Event listeners on
window,IntersectionObserver,ResizeObserver, media queries, online/offline. Though for most of these, post 8'suseSyncExternalStoreis the better primitive.Third-party widgets. A map, a chart, an editor that owns its own DOM. Set it up, tear it down.
Live connections. WebSockets, polling, server-sent events. Data that arrives because something else pushed it.
User-triggered client fetching. A search-as-you-type box hitting an endpoint. Though "should this be in the URL" is worth asking first.
Measurement. Read layout, adjust — usually
useLayoutEffect.
Notice the shape. Every one of them involves a system that exists whether or not React does, and that has to be told when to start and stop.
That's the whole job. Not "run this after render." Keep that thing over there in step with this thing over here — and clean up after yourself when the relationship changes.
What's next
That's the mechanism series done. Request to render to transport to mutation to state to effects — every step, in the order the machine runs it.
What's left is to put it in one place: the whole lifecycle as a single map, with every arrow pointing at the piece that explains it.
If this helped, or if I got something wrong — find me on LinkedIn or my site. I read everything.