Skip to content

    Frontend System Design

    Effects, Cleanup and Synchronizing With the Outside World

    Learn what a React Effect is for, how setup and cleanup pair up, why fetch Effects race, and how to fix them with an ignore flag or AbortController.

    By Published 12 min read

    • frontend-system-design
    • react
    • effects
    • race-conditions
    • abortcontroller
    Share

    Standard lessonintermediateReact labFrontend System Design

    Before this: How Hooks Work: State, Ordering and the Rules of Hooks

    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 ignore flag or an AbortController, 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.

    1. 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.
    2. 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.
    3. 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

    sequenceDiagram accTitle: Order of setup and cleanup for one Effect accDescr: React renders and commits, runs setup, later a dependency changes so React renders again, runs cleanup with the old values and then setup with the new values, and finally runs cleanup when the component unmounts. participant R as React participant C as Component participant X as External system R->>C: render (props A) R->>R: commit to the screen R->>X: setup for A (start) Note over R: a dependency changes to B R->>C: render (props B) R->>R: commit to the screen R->>X: cleanup for A (stop) R->>X: setup for B (start) Note over R: the component is removed R->>X: cleanup for B (stop)

    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

    flowchart TD accTitle: What Strict Mode's extra cycle reveals accDescr: In development an Effect runs setup, cleanup, setup. If cleanup undoes setup, one subscription or request is live afterwards. If cleanup is missing, two are live, which makes the bug visible. A["Mount in development"] --> B["setup"] B --> C["cleanup (extra, development only)"] C --> D["setup"] D --> E{"Does cleanup undo setup?"} E -->|"yes"| F["One live subscription or request"] E -->|"no"| G["Two live ones: the leak is visible now"]

    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

    sequenceDiagram accTitle: Two fetches answered out of order accDescr: The component requests user 1, then user 2. The response for user 2 arrives first and is shown. The older response for user 1 arrives last and overwrites it, so the screen says user 2 but shows user 1's data. participant UI as Component participant N as Network UI->>N: request user 1 UI->>N: request user 2 N-->>UI: response for user 2 Note over UI: shows user 2 (correct) N-->>UI: response for user 1 (late) Note over UI: shows user 1 under "Requested: user 2"

    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

    A component starts a request; the cleanup either hides the stale answer or cancels the requestProfile panel to One Effect run (dependency changed); One Effect run to Network (fetch); Network to One Effect run (response or AbortError)REACT COMPONENTProfile panel: render, EffectProfile panelrender, EffectOne Effect run: flag or controllerOne Effect runflag orcontrollerNetwork: any arrival orderNetworkany arrival orderdependency changedfetchresponse or AbortError

    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.

    1. Read src/fakeFetch.ts. Each request stays pending until a test calls respond(id, body), so the test decides the arrival order. The log records request #1 ..., abort #1, response #2.
    2. In test/profile-race.test.tsx, find the first test. It answers request 2, then request 1, and asserts the wrong heading (marked BUG). A passing test that asserts a bug documents the failure.
    3. 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.
    4. Find the Strict Mode tests: the abort variant records request #1, abort #1, request #2 on 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

    NOTE

    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

    Starting architecture: the panel fetches in an Effect with no cleanupSeller list to Profile panel (userId); Profile panel to Users API (GET /api/users/id); Users API to Profile panel (last response wins)BROWSERSeller list: click sets userIdSeller listclick sets userIdProfile panel: Effect on userIdProfile panelEffect on userIdUsers API: answers in any orderUsers APIanswers in anyorderuserIdGET /api/users/idlast response wins

    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

    1. Reproduce with a test. Write the out-of-order test first (answer request 2, then request 1) and watch it fail.
    2. Apply the fix in the Hook and watch it pass.
    3. Check Strict Mode: two requests per mount are expected; the first is aborted and shows no error.
    4. 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 AbortError as 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 ignore flag, an AbortController, or both; treat AbortError as 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.

    In this course:

    Official documentation

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments