useEffect as synchronization
React's useEffect hook, the escape hatch for synchronizing components with systems React doesn't own.
You are building a chat feature. Open the panel, and it has to connect to a WebSocket for the current room; close the panel, and it has to close that connection; switch rooms, and it has to drop the old connection and open a new one. No amount of useState, and no value derived in render, gets you there, because a live socket exists outside React’s render. React can’t compute it, only command it. Opening it, closing it, and re-pointing it when the inputs change is exactly the job useEffect exists for.
This is what an effect does: keep something React doesn’t own synchronized with your component’s current props and state. You write the setup, write the matching cleanup, and declare the dependencies that say when to re-run.
In 2026, almost everything that once lived in an effect has moved to a better-shaped tool, and what is left is this narrow case: synchronizing with systems React doesn’t own. So when you ask “should this be an effect?”, the default answer is no. useEffect is an escape hatch , taken sparingly and on purpose, and this lesson teaches the hatch itself, using the chat room throughout.
When to reach for useEffect
Section titled “When to reach for useEffect”Build the habit of starting from no. When you think “I’ll add an effect here,” override that default only when the task is genuinely synchronizing with something React doesn’t manage.
There are three shapes where the answer is yes. In a modern web app, these are essentially the only places useEffect is the right tool:
- Non-React subscriptions: a WebSocket, an
EventSourcefor server-sent events, aBroadcastChannel. A live connection you open, listen to, and close. - Third-party widgets that take a DOM node: a charting library you hand a
<div>, a map, Stripe Elements, a video player. Code outside React that needs to be created against a real element and torn down later. - Browser APIs React doesn’t model:
IntersectionObserver,ResizeObserver,matchMedia, raw scroll or resize listeners. Platform features with no React equivalent, that you subscribe to and unsubscribe from.
Every one has the same shape: something external gets created or connected, and later needs to be destroyed or disconnected.
The other side is everything that used to live in effects and now has a better home. You won’t learn the replacements today; the point is that when you see one of these tasks, “effect” should not be the word that comes to mind:
- Initial data the page needs → a Server Component or route loader fetches it directly.
- Cached or refetched server state → TanStack Query, or
use()for a streamed promise. - State that lives in the URL →
nuqs/useSearchParams, which you already saw. - Form submission state → Server Actions plus
useActionState. - A value you can compute from props or state → just compute it in render. Derive, don’t mirror.
- Resetting state when a prop changes → a
keyreset, which you already know too.
Before any mechanics, sort a handful of real tasks yourself. Deciding now, while it’s still abstract, is far cheaper than discovering you got it wrong inside real code.
Sort each task into whether `useEffect` is the right tool in 2026. Drag each item into the bucket it belongs to, then press Check.
divThe signature and the dependency array
Section titled “The signature and the dependency array”useEffect takes a setup function and an optional dependency array:
useEffect(setup, dependencies?);setup is the function that does the synchronizing. It optionally returns a cleanup function that undoes whatever setup did; the next section covers cleanup, so for now hold it as “an optional function that setup hands back.”
The second argument, the dependency array, controls when setup re-runs. It has three forms.
useEffect(() => { connectToLobby();});Almost always a bug. With no dependency array, setup runs after every render: every keystroke, every parent update. You will see this in old code; treat it as a mistake until proven otherwise.
useEffect(() => { connectToLobby();}, []);No reactive dependencies. Setup runs once on mount, cleanup once on unmount. This is the “connect once, disconnect once” shape, fine when the effect never needs to re-sync.
useEffect(() => { connectToRoom(roomId);}, [roomId]);Re-syncs when a listed value changes. You list the values the effect reads, and React re-runs setup whenever one of them changes, compared with Object.is. This is the “reconnect when the room changes” shape, and the one you’ll reach for most.
That Object.is check is the same referential equality React uses for the state bailout you met earlier: two values are equal when they’re the same primitive or the very same object reference.
Here is the canonical chat effect with its parts labeled:
useEffect(() => { const connection = connectToRoom(roomId); connection.connect(); return () => connection.disconnect();}, [roomId]);One rule prevents an error nearly everyone hits once: setup must be synchronous and must not return a Promise. Writing useEffect(async () => { ... }) is a type error, because an async function always returns a Promise, and React expects setup to return either nothing or a cleanup function. When you need async work inside an effect, put it in an inner function that you call, and keep setup itself synchronous.
useEffect(() => { const load = async () => { const data = await fetchRoomHistory(roomId); setHistory(data); }; load();}, [roomId]);This snippet is still missing a subtlety that we’ll fix when we deal with race conditions.
Cleanup runs before every re-sync, not just on unmount
Section titled “Cleanup runs before every re-sync, not just on unmount”A mental model you’ve probably absorbed elsewhere gets in the way here, so it’s worth naming and replacing.
The wrong model goes like this: useEffect(fn, []) means “run this when the component mounts,” and the cleanup means “run this when it unmounts.” It’s tempting because, for the empty-array case, it even produces the right behavior. But it breaks the moment a dependency enters the picture.
The right model: an effect’s job is to make the outside world match the current props and state. When a dependency changes, the world built from the old values is stale, so React tears it down and rebuilds it from the new ones. It runs cleanup, then setup: cleanup undoes the old sync, setup establishes the new one.
So cleanup is not “the unmount handler.” It runs before every re-sync, and also on unmount — whenever the effect is about to run again, the previous run gets cleaned up first.
The chat room shows the stakes. The user is in general and clicks random, so roomId changes from "general" to "random". React runs the cleanup first, disconnecting from general, then the setup, connecting to random. Skip the cleanup, as the wrong model invites you to, and the general connection never closes: the user now receives messages from both rooms, and every room switch stacks another live connection. That is the canonical leak, and it follows directly from thinking “unmount” instead of “re-sync.”
One connection, exactly as the first render asked. The socket is live in general.
roomId unchanged, deps equal by Object.is The component re-rendered, but the dependency didn't move — so the same connection stays untouched.
roomId changed — cleanup runs first The room changed to random, so the general connection is stale. It is closed before anything new opens.
A fresh connection opens to random. The world matches the current render once more.
Closing the panel fires the very same cleanup — the one that also runs between syncs. No socket is left dangling.
If step 3 felt familiar, it should: this is the cycle Strict Mode forced in development last lesson. Double-invoking a freshly mounted effect — setup, cleanup, setup again — runs this same tear-down-and-rebuild on purpose, so a missing cleanup surfaces immediately instead of in production. Strict Mode is a rehearsal of the real lifecycle you just scrubbed through.
Now the code behind that diagram, one part at a time.
'use client';
export const ChatRoom = ({ roomId }: { roomId: string }) => { useEffect(() => { const connection = connectToRoom(roomId); connection.on('message', addMessage); connection.connect(); return () => connection.disconnect(); }, [roomId]);
// ...render the message list};Start at the bottom. roomId is the one reactive value this effect reads, so it is the one dependency. This array is what tells React when the outside world has drifted, because a new roomId means re-sync.
'use client';
export const ChatRoom = ({ roomId }: { roomId: string }) => { useEffect(() => { const connection = connectToRoom(roomId); connection.on('message', addMessage); connection.connect(); return () => connection.disconnect(); }, [roomId]);
// ...render the message list};Setup builds the external thing. It opens a connection to the current room, attaches the message handler, and connects. Once this commits, the user is live in roomId.
'use client';
export const ChatRoom = ({ roomId }: { roomId: string }) => { useEffect(() => { const connection = connectToRoom(roomId); connection.on('message', addMessage); connection.connect(); return () => connection.disconnect(); }, [roomId]);
// ...render the message list};Cleanup tears down exactly what setup made: this connection, disconnected. React runs it before the next sync, meaning a room change, and on unmount. The same connection that went in is the one that comes out.
'use client';
export const ChatRoom = ({ roomId }: { roomId: string }) => { useEffect(() => { const connection = connectToRoom(roomId); connection.on('message', addMessage); connection.connect(); return () => connection.disconnect(); }, [roomId]);
// ...render the message list};Finally, the boundary. Effects only run in Client Components, and the directive marks this file as one. We’ll come back to why that matters at the end of the lesson.
To fix the timing in your mind, put the steps of a single room switch in order yourself. The one thing to get right is where the cleanup goes.
A chat component's `roomId` changes from `general` to `random`. Drag these into the order React runs them. The one thing to get right is where the cleanup sits. Drag the items into the correct order, then press Check.
useEffect(() => { const connection = connectToRoom(roomId); connection.connect(); return () => connection.disconnect();}, [roomId]);roomId = random general random random List every reactive value the effect reads
Section titled “List every reactive value the effect reads”It’s tempting to treat the dependency array as a dial: add a value to make the effect run more, remove one to make it run less. That framing produces bugs, because the array isn’t a dial. It’s a contract, and the contract is exact:
Every reactive value the setup reads belongs in the dependency array. A reactive value is anything that can change between renders: props, state, and any value computed from them. React uses the array to detect when the outside world has drifted from the current render and a re-sync is due. Read a value but leave it out, and React can no longer tell when to re-sync.
You don’t track this by hand. The lint rule react-hooks/exhaustive-deps reads your setup, finds every reactive value you reference, and flags any you missed. It ships in the default Next.js ESLint config at the warn level, and it’s one of the two hook rules you never disable, covered in full in this chapter’s Rules of hooks lesson. When it flags a missing dependency, add the dependency; don’t silence the rule.
Break the contract and you get a stale closure . The setup captured the value from the render where it last ran; if that value changes but the effect doesn’t re-run, the setup keeps using the old capture forever. This is the closure-by-reference trap you met earlier, now inside an effect. The code doesn’t error. It quietly uses yesterday’s data.
Two objections come up whenever the lint asks for a dependency. Neither justifies disabling the rule.
“Adding it makes my effect re-run too often.” You add currentUser because the effect reads it, and now the chat reconnects every time the user edits their display name. That’s wrong, but the fix isn’t to delete the dependency. This is a non-reactive read: the effect reads the value at event time, say onMessage(msg, currentUser) when a message arrives, and that value shouldn’t drive re-synchronization. You need to read the latest value without listing it as a trigger. That tool is useEffectEvent, the subject of the next lesson. Hold the distinction: reading a fresh value and re-running when it changes are different needs.
“The lint asks for things that never change.” Some values are stable by guarantee, and the lint knows it, so it won’t ask. The set functions from useState, dispatch from useReducer, and refs (ref.current) keep the same identity for the component’s whole life. Read them inside an effect without listing them, and the lint stays quiet.
The four canonical setup/cleanup pairings
Section titled “The four canonical setup/cleanup pairings”Every effect that creates something outside React returns a cleanup that destroys exactly that thing. The cleanup mirrors the setup, undoing the same object: if setup adds a listener, cleanup removes that listener.
Four pairings cover almost everything you’ll write. In each tab the setup line is on top, the cleanup below.
window.addEventListener('resize', onResize);return () => window.removeEventListener('resize', onResize);Add a listener, remove the same listener. The function reference must be identical in both calls. An inline arrow in each would create two different functions, and the cleanup would remove nothing.
const id = setInterval(tick, 1000);return () => clearInterval(id);Start an interval (or timeout), clear it by its id. This is the uncleared-interval leak from the previous lesson with the cleanup that fixes it. Without the clear, every mount stacks another ticking timer.
const unsubscribe = store.subscribe(onChange);return () => unsubscribe();A subscription API hands you an unsubscribe function. Capture it and call it in the cleanup. The API already gave you the teardown; use it.
const chart = createChart(node);return () => chart.destroy();A third-party instance (a chart, a map, Stripe Elements) is created against a DOM node and exposes destroy() or remove(). Create it in setup, destroy it in cleanup. This is the shape for every widget you mount into React.
This gives you a code-review habit: read any effect and ask what does the cleanup tear down? If it tears down the exact thing the setup created, the effect is sound. An empty cleanup when nothing external was created is a warning sign, usually one of the anti-patterns from a later lesson in disguise: a derived value that belongs in render, a handler’s logic that wandered into an effect, or a fetch that should be a Server Component.
The ResizePanel effect subscribes to resize events through subscribeToResize, which hands back an unsubscribe function — but the effect throws it away. Return a cleanup from the effect so closing the panel removes its listener. Click Toggle panel off and on a few times: a correct cleanup keeps the live-listener count from ever climbing above one.
Reveal the fix
useEffect(() => { const unsubscribe = subscribeToResize(() => {}); return unsubscribe;}, []);The effect returns the unsubscribe function it was already handed, so React runs it on unmount and removes the exact listener the setup added. Closing the panel now tears its listener down instead of leaving it attached, and toggling never stacks more than one.
Canceling or ignoring stale async responses in an effect
Section titled “Canceling or ignoring stale async responses in an effect”Most fetching is not an effect’s job in 2026, so read nothing here as an endorsement. But some cases remain: an SDK method that takes an AbortSignal, a one-off POST that shouldn’t be cached, a browser API that returns a promise. When async work does live in an effect, it carries a race condition.
In the chat room, the user clicks through rooms fast. You fire a request for general’s history, then immediately one for random’s. The network doesn’t promise order, so general’s response might arrive after random’s, and your setHistory would overwrite the correct new data with stale old data. The newest click loses. The fix, once again, is the cleanup.
There are exactly two patterns, and which you use depends on whether the async call can be cancelled.
useEffect(() => { const controller = new AbortController(); fetchRoomHistory(roomId, { signal: controller.signal }) .then(setHistory) .catch((error) => { if (error.name !== 'AbortError') throw error; }); return () => controller.abort();}, [roomId]);Use this when the call accepts an AbortSignal, the same AbortController you used for fetch earlier in the course. Pass it the signal and abort in the cleanup. On a room switch, the cleanup aborts the in-flight request before the new setup fires, so the stale response never reaches setHistory. The .catch swallows the expected AbortError and re-throws anything real.
useEffect(() => { let ignore = false; fetchRoomHistory(roomId).then((history) => { if (!ignore) setHistory(history); }); return () => { ignore = true; };}, [roomId]);Use this when the call can’t be cancelled. Don’t stop the request, just stop listening to it. A local ignore flag starts false, and the cleanup flips it to true. The resolved callback does nothing once the effect has been cleaned up, so the stale response still arrives but is ignored.
If the API accepts a signal, abort; otherwise, use the ignore flag. Aborting is the better choice when you can do it, because it stops wasted work, not just wasted state updates. The flag covers everything else.
Both keep the same shape: the async work lives in an inner .then chain, so the setup stays synchronous and returns a real cleanup. It is the same cleanup-on-re-sync mechanism as every other effect, which is why Strict Mode fires two requests in development: it surfaces a missing abort the way it surfaces any missing cleanup.
Object, array, and function dependencies change every render
Section titled “Object, array, and function dependencies change every render”This is the most common real-world useEffect bug. A dependency’s reference changes every render even though its contents are identical. Object.is compares identity, so React sees a change every time and re-runs the effect on every render. If the effect calls setState, you get an infinite loop: the new state triggers a render, the render builds a new dependency, the new dependency re-runs the effect, and the effect calls setState again.
The culprit is almost always an object or array literal. Every render, { id: 1 } evaluates to a brand-new object that is never Object.is-equal to last render’s, even though it looks identical.
const RoomPanel = ({ roomId }: { roomId: string }) => { const options = { roomId, theme: 'dark' }; return <Messages options={options} />;};
const Messages = ({ options }: { options: RoomOptions }) => { const [messages, setMessages] = useState<Message[]>([]); useEffect(() => { loadMessages(options).then(setMessages); }, [options]); // ...};New object every render → effect every render → setMessages every render → loop. The parent builds options fresh on each render, so it’s a different reference each time. Object.is always reports a change, and the effect never stops.
const Messages = ({ options }: { options: RoomOptions }) => { const [messages, setMessages] = useState<Message[]>([]); const { roomId, theme } = options; useEffect(() => { loadMessages({ roomId, theme }).then(setMessages); }, [roomId, theme]); // ...};Depend on the primitive fields the effect actually uses. Strings and numbers compare by value under Object.is, so the deps count as changed only when the real values change. The loop stops.
For an object or array dependency, work this ladder in order:
- Depend on the primitive fields you read. If the effect uses
options.roomIdandoptions.theme, list those, notoptions. This is the fix above, and it covers most cases. - Construct the object where it isn’t recreated each render, or memoize it upstream. The React Compiler (next chapter) usually handles this memoization for you, which is a reason not to reach for manual memoization first.
- Pass primitives from the parent rather than assembling an object only to tear it apart downstream.
Functions have the same problem. A function defined in the component body is a new reference every render, just like an object literal, so listing it as a dependency re-runs the effect every render. The ladder, in order of preference:
- Move the function inside the effect. If only the effect uses it, define it in the setup; then it isn’t a dependency at all.
- Move it outside the component. If the function captures no reactive values, hoist it to module scope, where it has one stable identity forever.
useCallback, but only when the function must be passed to a memoized child that depends on its identity. This narrow exception has a real cost; the next chapter covers when it’s warranted.useEffectEvent, when it’s an event-shaped read of the latest values. That’s the next lesson’s tool.
The moment you see “re-runs every render” or “infinite loop,” think identity: a dependency whose reference changes each render. Depend on the primitive instead.
A parent renders <Cart item={{ id: sku, qty: 1 }} />, building that object fresh each render. The child loops forever:
const Cart = ({ item }: { item: { id: string; qty: number } }) => { const [total, setTotal] = useState(0); useEffect(() => { setTotal(priceFor(item)); }, [item]); // ...};The effect fires on every render and the component never settles. Which change actually stops the loop?
// eslint-disable-next-line react-hooks/exhaustive-deps on the line above [item].total a second time in another useState and read from that copy.item.id in the array in place of item, so the dependency is a string.[item] array so the effect stops comparing dependencies.Cart a brand-new item object on every render, so Object.is reports it as changed each time and the effect re-runs — and since the effect calls setTotal, each run schedules the next render. The cure is to depend on a value that compares by contents, not by reference: item.id is a string, so the deps only register a change when the id actually differs, and the loop stops. Silencing the lint hides the warning while the loop runs on; deleting the array is strictly worse — with no array the effect runs after every render unconditionally.useLayoutEffect and useSyncExternalStore
Section titled “useLayoutEffect and useSyncExternalStore”useEffect has two siblings worth recognizing but rarely reaching for. Learn their triggers so they aren’t a mystery when you meet them in a library’s source, and keep useEffect as your default.
useLayoutEffect is the synchronous sibling. A regular useEffect runs after the browser paints; useLayoutEffect runs after React commits the DOM but before the paint. That matters in one situation: you need to measure a DOM node and change state from the measurement without the user seeing a flicker. The canonical case is a tooltip: measure its rendered width, then reposition it so it doesn’t spill off-screen, all before the first paint.
Reach for it only when a visible flicker is the actual problem, since blocking paint hurts performance and useEffect is correct and cheaper everywhere else. (A third sibling, useInsertionEffect, exists strictly for CSS-in-JS libraries; you’ll never write it.)
useSyncExternalStore is the correct primitive for reading a value that lives outside React (a window property, a third-party store, a BroadcastChannel) safely under concurrent rendering. It prevents tearing .
You will almost never call it directly. The libraries you use call it internally: a store like Zustand, and useSearchParams, are built on it. If you ever hand-integrate an external store yourself, this is the right tool, not useEffect plus useState.
Effects and the server boundary
Section titled “Effects and the server boundary”Effects only run in Client Components. They don’t run during server rendering, and never in a Server Component. That 'use client' boundary from the earlier walkthrough is exactly why the effect is allowed to run at all.
Because an effect doesn’t run on the server, anything that must be correct on the very first paint cannot live in an effect. First-paint data is what Server Components and use() (later in this chapter) are for; effects synchronize with the world after the page is alive, not to get it alive in the first place.
This builds a habit. When a component “needs an effect,” an experienced engineer’s first question isn’t “how do I write this effect?” but “should this even be a Client Component, or a Server Component that reads the data directly, with no effect required?” The Server/Client boundary that makes that answerable is taught in full when we reach the App Router.
External resources
Section titled “External resources”The official references below are worth a read. The React docs treat effects as synchronization in exactly the framing this lesson used, and the “You Might Not Need an Effect” page is the full version of the audit we previewed, which a later lesson will work through.
The complete reference: every parameter, the dependency rules, and the caveats around infinite loops and object deps.
The synchronization mental model this lesson is built on, with interactive connect/disconnect examples.
The full audit of when an effect is the wrong tool, with refactors. Teed up for a later lesson.
Dan Abramov's deep dive on why each render has its own effect — the source of the synchronization framing.