Skip to content

Effects as synchronization

An effect keeps a component in sync with something outside React; dependencies say when to re-sync, and cleanup undoes the previous sync.

Predict first

Before you read: predict

ExamHall proctor console. Development build, no StrictMode.

  1. The proctor opens candidate c-101's panel (ProctorStatus mounts with c-101).
  2. An alert-count update re-renders it with candidateId="c-101", alerts={1}.
  3. The proctor switches to candidate c-102.

What is the whole log, in order?

Code for this question
function ProctorStatus({ candidateId, alerts }) {  useEffect(() => {    log(`connect ${candidateId}`);    return () => log(`disconnect ${candidateId}`);  }, [candidateId]);  return <p>{candidateId}: {alerts} alerts</p>;}
Pick one

The 30-second version

After a commit, React runs an effect's setup only if a dependency changed (Object.is) since its last run; [] means mount only. Before re-running setup, React runs the previous cleanup; unmount runs it too. React undoes nothing itself, and development StrictMode's extra mount cycle exposes a missing cleanup. Values computable during render need no effect.

An effect keeps a component in sync with something outside React; dependencies say when to re-sync, and cleanup undoes the previous sync.

How it works

Step 1 of 8

Synchronize, not lifecycle. An effect describes how to start syncing one outside system (socket, subscription, timer, DOM API) and how to stop.

Analogy: A building's open-transition transfer switch (electrical)

A building takes power from one source at a time: the grid or its generator. An open-transition transfer switch changes source break-before-make: it opens the old source, then closes onto the new one, so the two are never connected together.

In the analogy In React
The source feeding the building The dependency values
The meter reading changes while the source stays the grid: no transfer A re-render with Object.is-equal dependencies: no re-run
Transfer: open the old source, then close onto the new Dependency change: previous cleanup, then new setup
Shutting the building down opens the switch Unmount runs the cleanup
At commissioning, the first connection is opened and closed once to prove the switch opens Development StrictMode's extra setup → cleanup on mount
The panel's load figure is calculated from the readings; nobody throws a switch for it A derived value is computed during render

Breaks down when:

  • Opening a switch cuts the current at once, but an in-flight network response still arrives after cleanup. Wrong inference: "cleanup alone stops a late response." It must be ignored or aborted (step 6).
  • The switch acts by itself; React runs only the cleanup you wrote. Wrong inference: "React disconnects the old subscription for me."
  • An electrician calls it the same source if it is the same grid; React compares identity. Wrong inference: "an object rebuilt with equal fields counts as unchanged."

Contrasting case: a technician presses the switch's test button because someone asked for a test, not because of which source is live. That is an event handler (step 8).

In real terms: after each commit, React compares every effect's dependencies with the previous run's; for each changed one it calls the stored cleanup, then the new setup, and it calls all cleanups on unmount.

Examples, run for real

Outputs are the literal stdout of the scripts named, 50/50 identical sequential and under 8-parallel load. Socket and fetch fakes are settled by the script, never by a timer.

Example 1 · ExamHall EXH-492: the old socket streams into the new view

InvigilatorCam opens a hall-camera socket per hallId. The invigilator switches from hall-A to hall-B; then the server sends one alert for each hall.

useEffect(() => {
  const ws = server.connect(hallId);
  ws.onmessage = (a) => setAlerts((list) => [...list, `${a.text} (${a.from})`]);
  return () => ws.close();            // MR !829 adds this line
}, [hallId]);
no cleanup | StrictMode off | open hall-A > open hall-B | open after mount: 1, at end: 2
                            | screen: viewing hall-B: seat 12 empty (hall-A), late arrival (hall-B)
no cleanup | StrictMode on  | open hall-A > open hall-A > open hall-B | open after mount: 2, at end: 3
                            | screen: viewing hall-B: seat 12 empty (hall-A), seat 12 empty (hall-A), late arrival (hall-B)
cleanup    | StrictMode off | open hall-A > close hall-A > open hall-B | open after mount: 1, at end: 1
                            | screen: viewing hall-B: late arrival (hall-B)
cleanup    | StrictMode on  | open hall-A > close hall-A > open hall-A > close hall-A > open hall-B | open after mount: 1, at end: 1
                            | screen: viewing hall-B: late arrival (hall-B)

Without cleanup, hall-A's socket keeps delivering into hall-B's view (step 4). StrictMode exposes the bug at mount: two sockets for one hall. With cleanup, StrictMode's extra cycle leaves one socket (step 5).

Example 2 · Catalog: a slow first response wins the race

ProductPage fetches in an effect keyed on productId. The shopper opens p-1, then p-2; the script resolves p-2 first and p-1 second.

useEffect(() => {
  let ignore = false;
  fetchProduct(productId).then((p) => { if (!ignore) setProduct(p); });
  return () => { ignore = true; };    // or: const ac = new AbortController(); … return () => ac.abort();
}, [productId]);
naive           | after p-2 arrives: url p-2 | showing Trail shoe | after p-1 arrives: url p-2 | showing Rain jacket
ignore          | after p-2 arrives: url p-2 | showing Trail shoe | after p-1 arrives: url p-2 | showing Trail shoe | dropped p-1
abortcontroller | after p-2 arrives: url p-2 | showing Trail shoe | after p-1 arrives: url p-2 | showing Trail shoe | p-1 fetch: AbortError

Without a guard, the late p-1 response overwrites p-2. The ignore flag drops it; aborting rejects it. Neither undoes the server's work (step 6); signals and server-side limits are in js-abort-cancellation.

Example 3 · Ticketing: you might not need an effect

OrderSummary shows a total derivable from price and qty. A layout effect records the DOM after each commit; qty goes from 1 to 2.

// A: const [total, setTotal] = useState(price * qty);
//    useEffect(() => { setTotal(price * qty); }, [price, qty]);
// B: const total = price * qty;
A effect + state  | qty 1 -> 2: renders 2, commits 2: "2 tickets, total €10" then "2 tickets, total €20"
B computed        | qty 1 -> 2: renders 1, commits 1: "2 tickets, total €20"

The effect version commits a wrong total, then renders again (step 7).

Picture it

Concept illustration for this lesson.
An illustration to help it stick. The mechanism is in the sections above.

Try it

Answer first, then check. Nothing is saved.

Question 1 of 6

(p1-streaming-leak.mjs, 50/50 sequential and 8-parallel, 1 distinct output)

Development build, StrictMode on. NowPlaying has useEffect(() => { feed.subscribe(trackId); }, [trackId]) and returns no cleanup. It mounts with t-1, re-renders with t-1 when the progress bar ticks, then the listener skips to t-2 and back to t-1.

How many lyric-feed subscriptions are open at the end?

Pick one

Question 2 of 6

(p2-chat-fix-options.mjs, 1 distinct output)

ChatRoom has useEffect(() => { const conn = connect(roomId); conn.onmessage = add; }, [roomId]). After switching from #general to #random, #random shows messages from #general.

Which change leaves exactly one open connection and shows only #random's messages?

Pick one

Question 3 of 6

(p3-catalog-derived.mjs, 1 distinct output)

An MR adds FilteredList: const [visible, setVisible] = useState([]); useEffect(() => { setVisible(items.filter((i) => i.includes(query))); }, [items, query]);. One keystroke changes query from "ch" to "cha".

How many times does FilteredList commit?

Pick one

Question 4 of 6

Same MR.

What one change do you ask for?

Question 5 of 6

Development build, no StrictMode. player is an outside audio player:

It mounts with t1 at volume 50. The listener drags the volume to 60, then 70, then picks track t2. Each volume step restarts t1 from the beginning.

Which single change keeps t1 playing through the volume changes, applies each volume at once, and then plays t2 alone?

Code for this question
function Player({ trackId, volume }) {  useEffect(() => {    player.load(trackId);    player.setVolume(volume);    return () => player.unload(trackId);  }, [trackId, volume]);  return <p>{trackId} at {volume}</p>;}
Pick one

Question 6 of 6

Development build, no StrictMode. A ticket shop shows CheckoutPanel when the buyer presses Checkout or taps a "Resume checkout" banner. While shown, the panel re-renders when the cart changes and once a second for the seat-hold countdown. Analytics needs track('checkout_viewed') exactly once each time the panel appears, however it was opened.

Where does the call go?

Pick one

Go deeper

Pro tips
  1. Keep <StrictMode> on in development and log each setup and cleanup; an unmatched "open, open" at mount means a missing cleanup.
    • When it helps: a subscription or socket leaks on every switch (steps 4–5).
    • The no-cleanup StrictMode row prints open hall-A > open hall-A and open after mount: 2; the cleanup row prints open after mount: 1.
    • Status: RAN (React 19.3.0, Node 24.19.0, jsdom).
  2. Enable react-hooks/exhaustive-deps from eslint-plugin-react-hooks, and fix a warning by restructuring the code, never by forcing [].
    • When it helps: an effect reads a prop that its array omits, as in the variant shape (step 3).
    • Verify: lint an effect that reads candidateId with []; the rule reports candidateId as a missing dependency.
    • Status: DOC (react.dev: exhaustive-deps, fetched 2026-09-27: "Validates that dependency arrays for React hooks contain all necessary dependencies").
Source
  • react.dev: Synchronizing with Effects. Fetched 2026-09-27. Covers effects running "at the end of a commit after the screen updates", the three dependency forms and Object.is, cleanup "before the Effect runs next time, and during the unmount", StrictMode's development-only remount, and the ignore pattern. Valid as of 2026-09-27.
  • react.dev: You Might Not Need an Effect. Fetched 2026-09-27. Covers computing derivable values during rendering, the restarted render pass when an effect sets state, event-specific logic in handlers, and useMemo for expensive calculations. Valid as of 2026-09-27.
  • react.dev: Lifecycle of Reactive Effects. Fetched 2026-09-27. Covers "An Effect can only do two things: to start synchronizing something, and later to stop synchronizing it", and the [] meaning. Valid as of 2026-09-27.
  • Supporting: react.dev: useEffect. Fetched 2026-09-27. Covers cleanup with the old values then setup with the new, "one extra development-only setup+cleanup cycle before the first real setup", and responses that "may arrive in a different order than you sent them".

Checked against the tools and docs as of 2026-09-27.