What you'll learn: By the end of this lesson you will be able to say what a React Effect is for, explain the order of setup and cleanup across re-renders, unmount and Strict Mode, predict when an Effect runs from its dependencies, reproduce the fetch race condition that a missing cleanup causes, fix it with an
ignoreflag or anAbortController, say when each is not enough, and decide when you do not need an Effect.
Think of moving house with a magazine subscription. A careful clerk cancels the old delivery before starting the new one. A clerk who only adds deliveries leaves copies arriving at two addresses, and nobody can say which is current.
A React component is in the same position. It renders from props and state, but some things it needs live outside React: a network request, a live feed, a timer, a third-party widget. An Effect is how the component says "start this when I appear, and stop it when it no longer matches". The stop half, the cleanup, is the clerk's cancellation. Most Effect bugs are a missing or incomplete cancellation.
The running example is a seller dashboard. A panel shows the selected seller's profile (fetched from /api/users/<id>) and a counter of open orders pushed by a live feed. The first version has no cleanup anywhere, and it works until the network answers out of order.
At the time of writing, react.dev lists React 19.3 as the latest version, and the lab runs on 19.3.1.
The core idea
Three statements carry the lesson.
- Rendering is pure; an Effect is for what rendering cannot do. React's docs describe Effects as code that runs after rendering "so that you can synchronize your component with some system outside of React", at the end of a commit, after the screen updates.
- An Effect can only start and stop one synchronization. The body starts it and the returned cleanup function stops it. It should not matter whether the component is mounting, updating or unmounting.
- Dependencies say what the synchronization depends on. When a value the Effect reads changes, the old synchronization no longer matches the UI, so React runs cleanup (with the old values) and then setup again (with the new ones).
The lifecycle of one Effect
Figure 1. Setup and cleanup across a change and an unmount
How to read this: setup and cleanup come in pairs. The cleanup for A runs after the new commit but before setup for B, and it sees A's values.
Dependencies. React compares each dependency with its previous value using Object.is. With no array the Effect re-runs after every commit; with [] it runs after the first commit only; with [a, b] it re-runs when a or b changed. You do not choose the list: it must contain every reactive value (props, state, anything computed in the component body) that the Effect reads, and React's linter enforces it. To run less often, change what the Effect reads, not the list. An object or function created during render is a new value every render, so as a dependency it re-runs the Effect each time.
Where Effects do not run. Effects run only on the client, not during server rendering, so a fetch inside an Effect cannot be part of server-rendered HTML.
Illustration 1 (minimal): a subscription
The open-orders counter is the smallest realistic Effect. The store is the outside system, and the unsubscribe function subscribe returns is the cleanup.
function LiveOrders({ store }) {
const [count, setCount] = useState(0);
useEffect(() => store.subscribe(setCount), [store]);
return <p role="status">Open orders: {count}</p>;
}
The lab's fake store records every call. Switching from store A to store B produces ["subscribe A", "unsubscribe A", "subscribe B"]: stop the old, start the new. Re-rendering with the same store records nothing, because Object.is says the dependency did not change.
Strict Mode: a rehearsal of leaving and coming back
In development, with <StrictMode>, React runs one extra setup and cleanup cycle on mount, so the Effect runs as setup, cleanup, setup. React's docs call it a stress test: it checks that your cleanup mirrors your setup, the same thing that happens if the user navigates away and back. It is development-only and does not change the production build.
Figure 2. What the double run reveals
How to read this: the extra cycle does not cause the bug, it exposes one that already exists.
In the lab, LiveOrders in Strict Mode records ["subscribe A", "unsubscribe A", "subscribe A"] and ends with one live subscription. LiveOrdersLeaky records ["subscribe A", "subscribe A"] and ends with two. Without Strict Mode the leaky version looks fine until it unmounts and keeps its subscription.
Fetching follows the same rule: two requests per mount in development, one in production. The fix is to make the first request harmless, as the next section shows.
Illustration 2 (realistic): the profile fetch and its race
The profile panel fetches the seller when userId changes. The first version:
function Profile({ userId, fetchImpl }) {
const [user, setUser] = useState(null);
useEffect(() => {
setUser(null);
fetchImpl(`/api/users/${userId}`)
.then((res) => res.json())
.then((body) => setUser(body)); // no cleanup
}, [userId, fetchImpl]);
return /* heading with user.name, "Requested: {userId}" */;
}
The shopper clicks seller 1, then seller 2, so two requests are in flight. Networks do not promise to answer in order: if seller 1's response arrives last, it calls setUser last and wins.
Figure 3. The race
How to read this: neither request failed. Nothing says the first request is now obsolete. React's docs call this a race condition.
The lab reproduces it without a clock: a fake fetch keeps each request pending until the test answers it. For ProfileNaive, answering request 2 and then request 1 leaves "Requested: u2" on screen under the heading "Ada", who is user 1. Strict Mode shows the same bug on one mount: with two requests for one user, ProfileNaive shows whichever answer arrives last.
Fix 1: ignore the stale response
useEffect(() => {
let ignore = false;
setUser(null);
async function load() {
const res = await fetchImpl(`/api/users/${userId}`);
const body = await res.json();
if (!ignore) setUser(body);
}
void load();
return () => {
ignore = true;
};
}, [userId, fetchImpl]);
Each Effect run has its own ignore variable. When userId changes, the cleanup of the old run sets its flag, so when the old response arrives it checks the flag and does nothing. React's docs recommend this shape: all responses except the last requested one are ignored. It does not stop the request itself.
Fix 2: abort the request
useEffect(() => {
const controller = new AbortController();
setUser(null);
async function load() {
try {
const res = await fetchImpl(`/api/users/${userId}`, { signal: controller.signal });
setUser(await res.json());
} catch (e) {
if (e.name === "AbortError") return; // expected, not an error
setError(e.message);
}
}
void load();
return () => controller.abort();
}, [userId, fetchImpl]);
AbortController.abort() aborts fetch requests, and an aborted fetch rejects with an AbortError DOMException (MDN). The code must treat that as normal, otherwise every cleanup shows an error. Aborting also saves work, because the browser can stop reading the response.
Figure 4. Where each fix acts
How to read this: each Effect run owns one request, and its cleanup decides what the late answer may do: be ignored (flag) or never arrive (abort).
Illustration 3 (failure): abort alone does not cover what happens after the fetch
Abort cancels the request. It does not cancel code you wrote after it. Suppose the Effect awaits one more step after the body is read, such as decoding or enriching the user, and that step cannot be cancelled. If the response for user 1 has already arrived when the shopper clicks user 2, the abort changes nothing (the request is finished) and the later setUser still runs.
The lab builds this with a gate the test releases. With the abort-only component, releasing user 2's step and then user 1's leaves "Requested: u2" over the heading "Ada"; with the flag version the screen stays on "Grace". The flag is checked right before the state update, the only moment that matters. The lab constructs this case on purpose, so it says nothing about how often it happens.
| Approach | What it stops | What it costs | Weakness |
|---|---|---|---|
ignore flag |
The state update from a stale run | A variable and a check | The request still runs to completion |
AbortController |
The request and reading its body | Handling AbortError as normal |
Does not stop later code you wrote |
| Both | The update and the request | A little more code | None of these two; still no caching or retries |
| Data library or framework | Races, caching, deduplication | A dependency and its conventions | Learning curve, bundle size |
React's docs note that these issues apply to any UI library, and that modern frameworks and client caches (they name TanStack Query, useSWR and React Router) handle fetching better than hand-written Effects. If you fetch in Effects, put the logic in one custom Hook so the cleanup is written once.
When you do not need an Effect
An Effect is an escape hatch to systems outside React. If nothing outside React is involved, React's docs say you should not need one.
Ask in this order: can the value be computed from props and state during render? Is the work caused by one user action, which belongs in an event handler? Should state reset when a prop changes, which a key does? Only if none applies, and the component must stay in sync with something outside React, is it an Effect with cleanup. The docs' test is "use Effects only for code that should run because the component was displayed to the user".
Resetting state with a key is the subject of the next lesson, Keys, Lists and Component Identity: a panel that should start fresh when userId changes can use a key instead of an Effect. For how requests behave once they leave the browser, see the request lifecycle; for why promise callbacks interleave the way they do, see the event loop and the main thread.
Build it: races and cleanup under test
Goal. Reproduce the fetch race and the Strict Mode leak, then watch each fix pass. The lab is examples/frontend/labs/react-effects-sync. It runs React 19.3.1 with Testing Library under jsdom, which is not a browser: it shows event order and DOM content, not speed, layout or real network behaviour.
cd examples/frontend
pnpm install
pnpm --filter ./labs/react-effects-sync test # 19 tests
Steps.
- Read
src/fakeFetch.ts. Each request stays pending until a test callsrespond(id, body), so the test decides the arrival order. The log recordsrequest #1 ...,abort #1,response #2. - In
test/profile-race.test.tsx, find the first test. It answers request 2, then request 1, and asserts the wrong heading (markedBUG). A passing test that asserts a bug documents the failure. - Read the abort test's log assertion:
["request #1 /api/users/u1", "abort #1", "request #2 /api/users/u2"]. The old run's cleanup happens before the new request. - Find the Strict Mode tests: the abort variant records
request #1,abort #1,request #2on one mount.
The test file lists what the run checks (names as vitest printed them):
the race: user u1 is requested, then u2, and the u1 response arrives last
ProfileNaive shows the wrong person under the right heading
ProfileIgnore drops the late u1 response
ProfileAbort aborts the u1 request, so no late response exists to arrive
aborting is not enough when a step after the fetch cannot be cancelled
ProfileAbort (signal only) writes the stale user
ProfileIgnore (flag) is still correct
Strict Mode (development) runs setup, cleanup, setup on mount
ProfileNaive lets the older of the two requests win if it arrives last
ProfileIgnore shows the response of the request that belongs to the live Effect
...
Tests 19 passed (19)
The last line is real Vitest 5.1.0 output from this run; the lines above it are test names. There is no demo and no golden file: Node cannot run .tsx, so the tests assert the exact traces.
Break it. In ProfileIgnore, delete return () => { ignore = true; };. "ProfileIgnore drops the late u1 response" fails. Revert.
Stretch. Combine abort and flag, and say which test needs which.
In practice: a seller dashboard that shows the wrong seller
This is a realistic scenario, not a case history. It is a composite built from common production patterns. It describes no specific company or product, and every number in it is an illustrative assumption.
The situation
A marketplace's seller dashboard has a seller list and a profile panel. Support reports that "sometimes the panel shows a different seller than the one I clicked, and it fixes itself on refresh". It happens on slow connections and when people click quickly, and nobody can reproduce it on the office network.
Starting architecture
Figure 5. The panel's data flow
How to read this: after a quick second click two requests are in flight, and the panel treats whichever reply lands last as current.
Options considered
| Option | What it fixes | What it costs | Risk |
|---|---|---|---|
ignore flag in the Effect |
Stale responses never reach state | A few lines per Effect | Requests still complete |
Add AbortController as well |
Saves bandwidth | Handle AbortError |
Server may still do the work |
Custom useSeller(id) Hook |
One place for the logic | A small refactor | Must be reused, not copied |
| Adopt a data-fetching library | Races, caching, retries | A dependency and migration | Team learns new conventions |
The decision
| Field | Decision record |
|---|---|
| Context | Fetch Effects have no cleanup, so a slow older response can overwrite a newer one. |
| Decision | Move the fetch into useSeller(id) with an ignore flag and an AbortController; treat AbortError as normal. |
| Consequences, good | The wrong-seller bug cannot occur from response order; fewer wasted requests. |
| Consequences, bad | Hook is custom code to maintain; no cache, so going back refetches. |
| Revisit when | Several screens need the same data, caching or retries. Then adopt a data library. |
Rollout and verification
- Reproduce with a test. Write the out-of-order test first (answer request 2, then request 1) and watch it fail.
- Apply the fix in the Hook and watch it pass.
- Check Strict Mode: two requests per mount are expected; the first is aborted and shows no error.
- Test the error path: a real failure still shows the alert, an abort does not.
The 2 a.m. failure
After the release, the panel flashes "Request failed" whenever a shopper clicks quickly, and client error logging spikes. The new code catches every rejection, and an aborted fetch rejects with an AbortError, so every cleanup is reported as a failure.
First three checks: (1) read the error name in the client log; (2) confirm the Effect's catch handles AbortError; (3) check the error state is cleared at the start of each run. Mitigation: ignore errors named AbortError in the catch. Follow-up: put the check in the shared Hook, and keep the test that asserts an abort shows no alert.
Architect's notes
- Decide on a fetching approach early. A dozen fetch Effects suggests a data library or a framework mechanism.
- Write the cleanup first. Name what the Effect starts and write the line that stops it.
- Keep the flag even with abort. The flag guards the state update; abort saves work.
Common mistakes
- Removing Strict Mode instead of fixing the cleanup.
- Fetching in an Effect with no cleanup, so the last response wins rather than the last request.
- Showing
AbortErroras a failure. It is the expected result of a cleanup. - Suppressing the exhaustive-dependencies lint rule, which points to a real mismatch.
- Putting an object or function created during render in the dependency list, so the Effect runs on every render.
- Using an Effect to compute a value from props and state, which belongs in render.
Key takeaways
- An Effect synchronizes a component with an external system after the screen updates; it is not a place for computation or event logic.
- An Effect does one thing: start synchronizing, and via cleanup stop it.
- On a dependency change React runs cleanup with the old values, then setup with the new ones; cleanup also runs on unmount.
- Dependencies are compared with
Object.is, and the list is dictated by what the Effect reads, not chosen. - Strict Mode (development only) runs setup, cleanup, setup on mount, which exposes missing cleanups.
- A fetch Effect without cleanup has a race condition: the last response wins.
- Fix it with an
ignoreflag, anAbortController, or both; treatAbortErroras normal. - Abort does not cancel code you wrote after the fetch; the flag guards the state update.
- With nothing outside React involved, compute in render, use an event handler, or reset with a key.
Practice exercises
Exercise 1 — Starter (30 min): Run the lab's tests. Write the order of events for LiveOrders in Strict Mode and explain why LiveOrdersLeaky ends with two subscriptions.
Exercise 2 — Intermediate (2 hours): Write a useUser(userId) Hook with the flag and the abort. Test it with a fake fetch that answers three requests in the order 3, 1, 2 and shows only the last requested user.
Exercise 3 — Advanced (half day): Add a retry to the Hook. Decide what happens to a retry in flight when userId changes, and test it.
Related reading
In this course:
- The Event Loop and the Main Thread — why promise callbacks run when they do.
- The Request Lifecycle — what happens to a request after
fetchis called.
Official documentation
- React: Synchronizing with Effects, what an Effect is, cleanup, the development remount and fetching.
- React: Lifecycle of Reactive Effects, start/stop synchronization and dependencies.
- React: useEffect reference, dependency comparison and cleanup timing.
- React: You Might Not Need an Effect, fetching races and when to avoid an Effect.
- React: StrictMode, the development-only extra Effect cycle.
- MDN: AbortController, aborting requests.