open atlas
↑ Back to track
React, zero to senior RCT · 03 · 01

Fetching in effects: races, waterfalls, and why the baseline is worth knowing

Fetching in useEffect is the honest baseline: an effect keyed on params, three states of boilerplate, and two structural bugs — races, because responses return out of order, and waterfalls, because the component tree serializes requests. Data libraries exist to fix exactly these.

RCT Middle ◷ 17 min
Level
FoundationsJuniorMiddleSenior
Already know this unit? Take a 1-minute quick check →

The bug report came with a screen recording: a user types “warsaw” into the flight search, the correct list flashes for half a second, then the page silently replaces it with flights to “wa” — every city in Washington state. No error, no crash, and it reproduced maybe one time in ten, always on the office Wi-Fi, never on the developer’s fiber connection. The fetch lived in a useEffect keyed on the query, exactly like the tutorial showed. What the tutorial didn’t show: the request for “wa” hit a cold cache and took 900 ms, the request for “warsaw” hit a warm one and returned in 120 ms, and the effect’s setResults(data) happily committed whichever response arrived last. The network had reordered the answers, and the component had no idea which question each answer belonged to. The fix was four lines — an ignore flag in the effect’s cleanup — and the team spent the rest of the sprint finding the same race in eleven other components. Fetching in effects is not wrong; it is the baseline. But the baseline has two structural failure modes, and you are about to meet both.

The honest baseline: an effect, a param, three states

A fetch-in-effect is a synchronization: “while this component shows query q, the screen should hold the server’s answer for q.” The effect re-runs whenever query changes, and you hand-write the three states every data UI needs — loading, error, data:

function FlightSearch({ query }) {
  const [results, setResults] = useState(null);
  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    let ignore = false;                    // race guard
    const controller = new AbortController(); // network cancel
    setLoading(true);
    setError(null);

    fetch(`/api/flights?q=${encodeURIComponent(query)}`, {
      signal: controller.signal,
    })
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then((data) => {
        if (!ignore) { setResults(data); setLoading(false); }
      })
      .catch((err) => {
        if (err.name === "AbortError") return; // expected on cleanup
        if (!ignore) { setError(err); setLoading(false); }
      });

    return () => {            // runs before re-run and on unmount
      ignore = true;
      controller.abort();
    };
  }, [query]);
  // render loading / error / results...
}

That is roughly twenty lines per endpoint, and every line earns its place. The shape to internalize: the effect body starts a request tied to one value of query; the cleanup disowns it. React guarantees cleanup runs before the next effect body and on unmount — that ordering is the entire correctness story.

The race: responses do not return in the order you sent them

Type “w”, “wa”, “war” quickly and you issue three requests. HTTP gives no ordering guarantee across requests — caches, retries, and server load reorder them freely. Without a guard, each response calls setResults, and the slowest request paints last. The two guards do different jobs. The ignore flag is correctness: a closure variable per effect run; when cleanup flips it, the stale closure’s setResults becomes a no-op. The response still downloads — you just refuse to commit it. AbortController is efficiency: it cancels the network work itself, freeing the browser’s per-origin connection slots (HTTP/1.1 allows 6) and the server’s time. Abort alone is almost enough — the rejected promise exits via AbortError — but the ignore flag also protects non-abortable async work after the fetch (a res.json() already in flight, a chained transform), so the pair is the idiom. StrictMode runs setup→cleanup→setup on dev mount precisely to prove your cleanup actually disowns the first request: if you see doubled commits in dev, the production race is real.

Quiz

A search effect uses only AbortController, no ignore flag. The query changes from "wa" to "warsaw"; cleanup aborts the first request. Is the race fully closed?

Waterfalls: the component tree becomes your network schedule

The second structural problem is invisible in any single component. ProfilePage fetches the user, renders <Posts userId={...}> only after the user arrives, and Posts fetches posts, then renders <Comments> which fetches again. Each fetch starts only after the parent’s data has landed and the child has mounted — so three logically independent requests run in series. At 200 ms per round trip that is 600 ms of pure structure-imposed latency before counting server time; on a 75 ms-RTT LTE connection with TLS setup, real traces routinely show 1.5–2 s waterfalls on pages that could load in one round trip. The network panel signature is unmistakable: a staircase. The tree decided your schedule, and the tree knows nothing about the network. Cures, in increasing order of ambition: hoist the fetches to the highest component that knows all the parameters and run them with Promise.all; start fetches before rendering (in the route loader or event handler) and pass promises down; or hand the problem to a library or framework whose cache decouples “who renders this” from “who fetches this”. That last move is the subject of the rest of this unit.

Quiz

ProfilePage fetches a user, then renders Posts which fetches posts, which renders Comments which fetches comments. RTT is 200 ms. What's the minimum structure-imposed delay before comments start downloading, and why?

Recall before you leave
  1. 01
    Explain the fetch-in-effect race mechanically: where does the stale write come from, and what exactly do the ignore flag and AbortController each contribute?
  2. 02
    A page shows a 4-step staircase in the network panel. Why does fetching in effects produce this shape, what does it cost, and what are three escalating fixes?
Recap

Fetching in useEffect is the baseline pattern every improvement is measured against: an effect keyed on the request parameters starts a fetch, hand-rolled loading/error/data state covers the three UI moments, and cleanup — guaranteed to run before the next effect body and on unmount — is where correctness lives. The first structural bug is the race: HTTP responses return in any order, so the slowest request paints last unless each effect run disowns its request in cleanup; the ignore flag turns the stale closure’s setState into a no-op (covering even non-abortable steps like an in-flight res.json()), while AbortController cancels the actual network work and frees connection slots — production code pairs them, and StrictMode’s dev setup→cleanup→setup cycle smoke-tests the guard. The second structural bug is the waterfall: components that fetch on mount can only start after their parents’ data committed, so logically parallel requests serialize down the tree — three levels at 200 ms RTT is 600 ms of latency the network panel draws as a staircase. Hoisting fetches to a common ancestor with Promise.all, starting requests before render, or adopting a cache that separates fetching from rendering are the escalating cures. Twenty lines of boilerplate per endpoint, two systemic failure modes — that is the honest cost the rest of this unit’s tools exist to repay. Now when you see a stale-data bug or a waterfall in the network panel, you know exactly which structural property to reach for first: cleanup ownership for races, fetch hoisting or a cache for waterfalls.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 7 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.