Skip to content
Chapter 25Lesson 2

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.

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:

  1. Non-React subscriptions: a WebSocket, an EventSource for server-sent events, a BroadcastChannel. A live connection you open, listen to, and close.
  2. 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.
  3. 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 URLnuqs / 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 key reset, which you already know too.
Moved away from effects 6
Initial page data Server Component / route loader
Cached server state TanStack Query / use()
URL state nuqs / useSearchParams
Form submission Server Actions + useActionState
Derived value compute in render
Reset on prop change key reset
Stays in useEffect 3
Non-React subscriptions WebSocket · EventSource · BroadcastChannel
Widgets that take a DOM node chart · map · Stripe Elements
Browser APIs React doesn't model IntersectionObserver · ResizeObserver · matchMedia
Six jobs moved to other tools; three stayed. The default answer to 'should this be an effect?' is no.

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.

Reach for useEffect Synchronizing with a system React doesn't own
Use something else A better-shaped tool already owns this job
Keep a WebSocket open while a chat panel is mounted
Position a third-party chart inside a div
React to the viewport crossing a sentinel element
Show the sum of line items in a cart
Load the dashboard’s initial data
Reset an edit form when a different record is selected
Submit a form and show a pending state

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.

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.”

setup Mount — setup runs for the first time
component <ChatRoom /> roomId: "general"
connected
server general

One connection, exactly as the first render asked. The socket is live in general.

Setup runs. The connection to general opens — the outside world now matches the first render.
render Re-render — roomId unchanged, deps equal by Object.is
component <ChatRoom /> roomId: "general" re-rendered
effect skipped
server general

The component re-rendered, but the dependency didn't move — so the same connection stays untouched.

A re-render where roomId is the same. The deps are equal by Object.is, so the effect is skipped — nothing reconnects. Re-renders alone don't re-run effects.
cleanup roomId changed — cleanup runs first
component <ChatRoom /> roomId: "random"
disconnect general
server general

The room changed to random, so the general connection is stale. It is closed before anything new opens.

roomId changed, so the old sync is stale. Cleanup runs first: disconnect from general. The old setup is torn down before the new one is built.
setup Setup runs again — connect to the new room
component <ChatRoom /> roomId: "random"
connected
server random

A fresh connection opens to random. The world matches the current render once more.

Then setup runs: connect to random. The outside world matches the current render again.
cleanup Unmount — the same cleanup runs one last time
component <ChatRoom /> unmounted
nothing open
server closed

Closing the panel fires the very same cleanup — the one that also runs between syncs. No socket is left dangling.

The panel closes. The same cleanup that runs between syncs runs one last time on unmount: disconnect from random. Nothing is left open.

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.

1 / 1

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]);
React commits the new render with roomId = random
The old effect’s cleanup runs — disconnect from general
The new setup runs — connect to random
Messages now arrive only from 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.

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.

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.

Preview
    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.

    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.

    For an object or array dependency, work this ladder in order:

    1. Depend on the primitive fields you read. If the effect uses options.roomId and options.theme, list those, not options. This is the fix above, and it covers most cases.
    2. 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.
    3. 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:

    1. 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.
    2. Move it outside the component. If the function captures no reactive values, hoist it to module scope, where it has one stable identity forever.
    3. 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.
    4. 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?

    Silence the warning with // eslint-disable-next-line react-hooks/exhaustive-deps on the line above [item].
    Store total a second time in another useState and read from that copy.
    List item.id in the array in place of item, so the dependency is a string.
    Delete the [item] array so the effect stops comparing dependencies.

    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.)

    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.

    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.