Skip to content
Chapter 25Lesson 2

useEffect as synchronization

React's useEffect hook, the escape hatch for synchronizing your components with systems React doesn't own.

You are building a chat feature. When the panel opens, it has to connect to a WebSocket for the current room. When the panel closes, it has to close that connection. When the user clicks a different room, it has to drop the old connection and open a new one. No amount of useState, and no amount of deriving values 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.

That is what this lesson covers: how to keep something outside React synchronized with your component’s current props and state. We are going to open the black box you met in the previous lesson, which is setup plus an optional cleanup, and learn to write the setup, write the matching cleanup, and read the dependency array.

Before any syntax, though, there is a warning that changes how you’ll read everything that follows.

In 2020, useEffect was the hook you reached for constantly. Fetching data on mount went in an effect. Keeping the URL in sync with a filter went in an effect. Resetting a form when the selected record changed, computing a derived value: effects, all of it. It was the catch-all, and codebases filled up with them. In 2026, every one of those jobs has moved to a better-shaped tool, and what is left for useEffect is a narrow set of cases: synchronizing with systems React does not own. The most common mistake a junior makes with this hook in 2026 is reaching for it at all, so we will lead with when not to use it, then teach the mechanics for the cases where one is genuinely warranted.

useEffect is an escape hatch : a deliberate step outside React’s normal data flow, taken sparingly and on purpose. A later lesson, “You probably don’t need an effect,” works through the full audit of when to take that step. This lesson teaches the hatch itself.

By the end you’ll be able to write a correct connect, disconnect, and reconnect effect, clean up four different kinds of external resource, and avoid the infinite-loop and stale-data traps that make effects feel unpredictable. We’ll keep coming back to the chat room the whole way, so you’re never re-orienting to a new example.

The decision comes before the API. The habit worth building is this: when you think “I’ll add an effect here,” start from the answer no, and only override it when the task is genuinely one of synchronizing with something React doesn’t manage.

There are three shapes where the answer is yes. In a 2026 SaaS 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 of these has the same shape: something external gets created or connected, and later needs to be destroyed or disconnected. Hold onto that shape, because it carries the whole lesson.

Here is the other side: what used to live in effects, and where each piece went instead. You won’t learn the replacements today. The point is to start building the habit, so that when you see one of these tasks, “effect” is not the word that comes to mind:

  • Initial data the page needs → a Server Component or route loader fetches it directly. (Covered when we reach the App Router.)
  • Cached or refetched server state → TanStack Query, or use() for a streamed promise. (use() is later in this chapter; TanStack Query comes in its own chapter.)
  • State that lives in the URLnuqs / useSearchParams, which you already saw.
  • Form submission state → Server Actions plus useActionState. (Its own unit.)
  • A value you can compute from props or state → just compute it in render. You already know this one: derive, don’t mirror.
  • Resetting state when a prop changes → a key reset, which you already know too.

Notice that the last two are things you already do. The 2026 narrowing isn’t a pile of new rules to memorize; it’s mostly a consolidation of habits you’ve already built. You derive in render instead of syncing into state, and you reset with key instead of an effect. Those instincts were already pulling work away from effects. What this section adds is the name for the boundary, and the fact that almost everything lives on the other side of it.

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. Making the decision 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

Start with the hook in its simplest form:

useEffect(setup, dependencies?);

setup is a function: the code that does the synchronizing. It optionally returns a cleanup function, the code that undoes whatever setup did. We’ll spend the next section on cleanup; for now, hold it as “an optional function that setup hands back.”

The second argument, the dependency array, is where most of the behavior lives. There are three forms, and getting the shape of this array right is one of the most useful skills in the lesson. Here they are side by side.

useEffect(() => {
connectToLobby();
});

Almost always a bug. With no dependency array at all, the setup runs after every single render: every keystroke, every parent update. You will see this in old code; treat it as a mistake until proven otherwise.

The comparison in the third form is the same referential equality React already uses for the state bailout you met earlier, so you don’t need to relearn it. Object.is treats two values as equal when they’re the same primitive or the very same object reference, and React runs that exact check on each dependency to decide whether anything changed.

Here is the canonical chat effect with its parts labeled, so the moving pieces have names before we put them in motion.

useEffect(() => {
const connection = connectToRoom(roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]);

One rule to set down now, because it prevents an error nearly everyone hits once: the setup function must be synchronous, and it must not return a Promise. Writing useEffect(async () => { ... }) is a type error. An async function always returns a Promise, but React expects setup to return either nothing or a cleanup function, and a Promise is neither. When you do need async work inside an effect, and sometimes you genuinely do, the async work lives in an inner function that you call, while the setup itself stays synchronous.

useEffect(() => {
const load = async () => {
const data = await fetchRoomHistory(roomId);
setHistory(data);
};
load();
}, [roomId]);

We’ll come back to the async case properly when we deal with race conditions, because the snippet above is still missing a subtlety. For now, hold the rule: setup is synchronous, and async work goes in a function that setup calls.

The lifecycle is synchronization, not “mount and unmount”

Section titled “The lifecycle is synchronization, not “mount and unmount””

This section is where a mental model you’ve probably absorbed elsewhere gets in the way, so it’s worth naming the wrong model and replacing it deliberately.

The wrong model goes like this: useEffect(fn, []) means “run this when the component mounts,” and the cleanup means “run this when the component unmounts.” Developers who learned an older React vocabulary call these “componentDidMount” and “componentWillUnmount.” It’s a tempting picture because, for the empty-array case, it even produces the right behavior. But it’s the wrong model, and it produces a specific, hard-to-spot bug the moment a dependency enters the picture.

The right model is this: 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 now stale, so React tears it down and builds it again from the new values. Concretely, React runs cleanup, then setup: it runs your cleanup to undo the old sync, then runs setup to establish the new one.

So cleanup is not “the unmount handler.” Cleanup runs before every re-sync, and also on unmount. Whenever the effect is about to run again, the previous run gets cleaned up first.

Watch it happen in the chat room. The user is in general and clicks random, so roomId changes from "general" to "random". React runs the cleanup first, disconnecting from general, and then the setup, connecting to random. If you had written the effect with the wrong mental model and skipped the cleanup, the general connection would never close. The user would now be receiving messages from both rooms, and every room switch would stack another live connection. That is the canonical leak, and it follows directly from thinking “unmount” instead of “re-sync.”

Scrub through the full lifecycle in the diagram below, and notice where the cleanup sits: between syncs, not only at the end.

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 exactly the cycle Strict Mode forced in development in the previous lesson. When Strict Mode double-invokes a freshly mounted effect, running setup, cleanup, then setup again, it is performing this same tear-down-and-rebuild on purpose. It does this in development so that a missing cleanup shows up immediately instead of in production. The lifecycle you just scrubbed through is the real thing, and Strict Mode is a rehearsal of it.

Now the code that produces that diagram. Walk through it 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

The dependency array is a contract, not a tuning knob

Section titled “The dependency array is a contract, not a tuning knob”

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’s the wrong frame, and treating it that way is what produces the bugs that come from giving React a dependency list that doesn’t match what the setup reads. The array is 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. If you read a value but leave it out of the array, the contract is broken, and React can no longer tell when to re-sync.

You don’t have to track this by hand. The lint rule react-hooks/exhaustive-deps reads your setup, finds every reactive value you reference, and flags any you forgot. It ships in the default Next.js ESLint config and runs at the warn level. This is one of the two React hook rules from the previous chapter that you never disable. It is checking your work for you, so when it flags a missing dependency, the right response is to add the dependency, not to silence the rule.

What goes wrong if the array doesn’t match what the setup reads? 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, because you left it out of the array, the effect keeps using the old captured value forever. You met closures capturing by reference earlier in the course, and this is that exact trap, now inside an effect. The code doesn’t error. It just quietly uses yesterday’s data.

Two objections come up constantly when the lint asks for a dependency, and both feel like reasons to disable the rule. Neither one is.

“But adding it makes my effect re-run too often.” You add currentUser to the deps because the effect reads it, and now the chat reconnects every time the user edits their display name. That’s clearly wrong: changing your name shouldn’t drop your socket. The fix, though, is not to delete the dependency. This is the non-reactive read: the effect reads a value at event time, for example when a message arrives and you call onMessage(msg, currentUser), and that value genuinely should not drive re-synchronization. What you want is a way to read the latest value without listing it as a trigger. That tool is useEffectEvent, and it is the entire subject of the next lesson. For now, hold the distinction clearly: reading a fresh value and re-running when it changes are two different needs, and the answer is one lesson away.

“The lint is asking for things that never change.” Some values are stable by guarantee, and the lint already knows it, so it won’t ask for them. The set functions from useState and the dispatch from useReducer keep the same identity for the component’s whole life, which React promises, as you saw when we covered state. Refs (ref.current) are the same. You can read these inside an effect without listing them, and the lint won’t complain.

So the discipline is small and firm: when you read a reactive value, list it. If listing it causes a re-run you don’t want, that is a signal you have a non-reactive read. Reach for the tool in the next lesson, never the eslint-disable.

One rule is what keeps effects safe instead of leaky: 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 added a listener, cleanup removes that listener. If setup opened a connection, cleanup closes that connection.

There are four canonical pairings you’ll write over and over. Here they are as a reference, with the setup line on top and the cleanup line below in each tab.

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 worth keeping. Read any effect and ask one question: what does the cleanup tear down? If the cleanup tears down the exact thing the setup created, the effect is sound. If the cleanup is empty and nothing external was actually created, that is a warning sign. An effect with nothing to clean up is usually one of the anti-patterns from a later lesson in disguise: a derived value that should be computed in render, a handler’s logic that wandered into an effect, or a fetch that should be a Server Component. The empty cleanup is the signal that something is off.

Now write one yourself. The next exercise hands you an effect that subscribes but never cleans up. Your job is to add the cleanup and watch the leak stop.

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.

    Two race patterns for async inside an effect

    Section titled “Two race patterns for async inside an effect”

    Most fetching is not an effect’s job in 2026. We said so at the top, and the point stands here too, so nothing in this section should read 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 you have to know how to handle.

    Here is the race, in the chat room. The user clicks through rooms fast. You fire a request for general’s history, then immediately a request for random’s. But 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. That is the bug, and the fix is, once again, the cleanup.

    There are exactly two patterns, and which one 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, which is 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 resolves into setHistory. The .catch swallows the expected AbortError and re-throws anything real.

    The selection rule is simple: 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 and not just wasted state updates. The flag covers everything else.

    Notice the shape underneath both. The async work lives in an inner promise chain (.then), so the setup itself stays synchronous and returns a real cleanup. That is the rule from the signature section now paying off. And this is the same mechanism as every other effect: cleanup on re-sync. We are not learning a new lifecycle, only applying the lifecycle you already know to async work. It is also why Strict Mode fires two requests in development: it surfaces a missing abort the same way it surfaces every other missing cleanup.

    When dependencies are objects, arrays, and functions

    Section titled “When dependencies are objects, arrays, and functions”

    This is the most common real-world useEffect bug, and its fix is not obvious the first time you hit it. Here is the trap: a dependency whose reference changes every render, even though its contents are identical. Since Object.is compares identity, React sees “changed” every single time, so the effect re-runs on every render. If that 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 time a component renders, { id: 1 } evaluates to a brand-new object. It is never Object.is-equal to the one from the last render, even though it looks identical. Walk through the failure and the fix.

    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 of its renders, so options is a different reference each time. Object.is always reports a change, and the effect never stops re-running.

    When an object or array dependency is the problem, there’s a fix ladder. Try these in order:

    1. Depend on the primitive fields you actually 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. Move the object construction so it isn’t recreated each render, or memoize it upstream. (The React Compiler, which the next chapter covers, usually handles this memoization for you, which is a reason not to reach for manual memoization first.)
    3. Restructure so the parent passes primitives rather than assembling an object only to tear it apart again downstream.

    Functions have the same problem. A function defined inside the component body is a new reference on every render, just like an object literal. List it in a dependency array and the effect re-runs every render. The fix 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, and the problem vanishes.
    2. Move it outside the component. If the function captures no reactive values, hoist it to module scope. Module-level functions have one stable identity forever.
    3. useCallback, but only when the function must be passed to a memoized child that depends on its identity. This is a narrow exception with a real cost; the next chapter covers when it’s warranted. Don’t reach for it reflexively.
    4. useEffectEvent, when it’s an event-shaped read of the latest values. That’s the next lesson’s tool.

    The skill to build here is fast recognition. The moment you see “re-runs every render” or “infinite loop,” your first thought should be identity: a dependency whose reference changes each render. Your first move should be to depend on the primitive instead.

    Diagnose one before moving on.

    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.

    useLayoutEffect and useSyncExternalStore: the two specialist variants

    Section titled “useLayoutEffect and useSyncExternalStore: the two specialist variants”

    useEffect has two siblings you should be able to recognize but will rarely reach for. The goal here is to make their triggers easy to spot, so they aren’t a mystery when you meet them in a library’s source, and so you keep useEffect as the default you almost always want.

    useLayoutEffect is the synchronous sibling. A regular useEffect runs after the browser paints; useLayoutEffect runs after React commits the DOM but before the browser paints. That difference matters in exactly one situation: you need to measure a DOM node and change state based on 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. Because it blocks paint, overusing it hurts performance, and useEffect is correct and cheaper everywhere else. (There’s a third sibling, useInsertionEffect, that’s strictly for CSS-in-JS libraries; you’ll never write it.)

    Both of these are exceptions. useEffect is the default for synchronization. Reach for useLayoutEffect only to prevent a flicker, and you’ll likely go months without writing useSyncExternalStore at all.

    One last fact closes the loop on everything we opened with: effects only run in Client Components. They don’t run during server rendering, and they never run in a Server Component. You saw the 'use client' directive in the walkthrough earlier, and that boundary is exactly why the effect is allowed to run there at all.

    This gives you a habit worth more than any single piece of syntax. When a component “needs an effect,” an experienced engineer’s first question isn’t “how do I write this effect?” It is “should this even be a Client Component? Could this be a Server Component that reads the data directly, with no effect required?” That question, and the Server/Client boundary that makes it answerable, is taught in full when we reach the App Router. For now, just build the habit of asking it.

    It also ties back to the lesson’s opening. 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 precisely what Server Components and use() (later in this chapter) are for. So the narrowing we started with isn’t just a list of better tools; the runtime itself enforces it. Effects are for synchronizing with the world after the page is alive, not for getting it alive in the first place.

    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.