Index

Fetching data, and the race condition

Two questions hide inside "how do you fetch data in React". The junior one is the mechanics: an effect, a loading flag, res.ok, a cleanup. The senior one is the observation that you are now maintaining a cache of somebody else's data in your component - and that server state is not the same kind of thing as client state, which is why a form's open/closed flag belongs in useState and a list of users does not. In between sits the race condition, which is the single question most likely to decide the round: an older request resolving after a newer one, and the two ways to make sure the loser cannot win.

There are four states, not two

A boolean loading is not enough, and an interviewer will find the gap in about a minute. There is idle (nothing has been asked for yet - no id selected, no search term typed), loading, error, and success. Idle is the one people drop, and dropping it is why an empty search box shows a spinner forever or a table renders "no results" before anyone asked for any.

Model it as one status string rather than three booleans. Three booleans can be in states that make no sense - loading and error both true - and every render then has to guard against combinations that should be impossible. One status of "idle" | "loading" | "error" | "success" cannot lie.

The other half is that data and error should be cleared when a new request starts, or a failed refetch leaves last week's data on screen under a fresh error message, and nobody can tell which one is true.

Client state and server state are different things

Client state is owned by this browser tab: which modal is open, what is in the form, which tab is selected. It is synchronous, it is never out of date, and nobody else can change it. useState is the right home for all of it.

Server state is a COPY of something that lives somewhere else. It is asynchronous, it can be stale the moment it arrives, it is shared with everyone else using the system, and it needs to be invalidated, refetched, deduplicated and cached. useState plus useEffect gives you none of that - it gives you a variable holding a snapshot, and every feature you add on top (retry, refetch on focus, don't-refetch-if-fresh) is you rebuilding a cache by hand.

That distinction is the sentence senior candidates say and juniors do not, and it is the honest reason React Query and SWR exist. It is also the answer to "should I put my API data in Redux?" - you can, and then you own the caching.

Stale-while-revalidate
Show the cached value immediately, fire a request in the background, and swap in the fresh value when it lands. The user sees data instantly and correctness catches up. It is the default in SWR and React Query, and the name of the HTTP Cache-Control directive it is borrowed from.
Cache key
The identity of a request - usually the endpoint plus its parameters, like ['users', page, query]. Two components asking for the same key get one request and one shared result; a changed key is a different request. Getting the key wrong is how you serve page two's data on page three.
Invalidation
Marking cached data as out of date after a mutation, so the next read refetches it. "Post the comment, then invalidate the comments for this article" is the whole pattern, and it is what an effect-based fetch has no way to express.

Questions

Answer each one yourself before you open it.

  1. 01How do you fetch data in a React component?

    In an effect keyed on whatever the request depends on. Set loading, call fetch, check res.ok, parse, set data or error, and return a cleanup that aborts the request. In a real app, with a data library instead.

    The effect is the right place because fetching is synchronising with something outside React - the same category as a subscription. What makes it hard is not the fetch, it is everything the effect has to handle: the dependency changing mid-flight, the component unmounting mid-flight, and the fact that two requests can be alive at once.

  2. 02Why can't the effect callback itself be async?

    Because an async function returns a promise, and React expects the effect's return value to be a cleanup function. React warns, and your cleanup silently never runs. Define an async function inside the effect and call it, or use .then.

    The warning is "useEffect must not return anything besides a function, which is used for clean-up". The damage is worse than the warning suggests: no cleanup means no abort, which means the race condition is back.

  3. 03Does fetch reject on a 404?

    No. fetch only rejects on a network-level failure - connection refused, DNS failure, CORS, an abort. A 404 or a 500 is a perfectly successful HTTP exchange, so the promise resolves and res.ok is false.

    This is the single most common fetch bug: a try/catch around a fetch with no res.ok check catches nothing, res.json() throws on the error page's HTML, and the user gets "Unexpected token < in JSON" instead of "not found". Throw yourself: if (!res.ok) throw new Error(...), and now your catch block is meaningful.

    Follow-up What does axios do differently?

    It rejects on non-2xx by default, which is why people find it easier - and it parses JSON for you. That is the whole practical difference for this question.

  4. 04Show me the try/catch/finally shape.

    try the fetch, the res.ok check and the parse; catch sets the error state after ignoring AbortError; finally clears the loading flag so it clears on both paths.

    Without the finally you have to remember to clear loading in two places, and the one you forget is the error path - so a failed request leaves a spinner running under an error message. The one thing that must NOT go in the finally is a loading clear for an aborted request: that request was cancelled on purpose and a newer one is already loading.

  5. 05How do you cancel an in-flight request?

    const controller = new AbortController(), pass controller.signal into fetch, and return () => controller.abort() as the effect's cleanup. The fetch promise rejects with an error whose name is AbortError, which you ignore.

    React runs that cleanup both when the component unmounts and before the effect re-runs for a new dependency - which is exactly the two moments an in-flight request has become irrelevant. One line of cleanup handles both.

    Follow-up Is a cancelled request still sent?

    It was sent; abort tells the browser to stop waiting and tear down the connection. The server may well have already done the work - abort protects your UI, not your backend, which is why it is not a substitute for idempotency on a mutation.

  6. 06Describe the race condition.

    The user picks user 1, then quickly picks user 2. Two requests are in flight. Request 1 is slower and resolves last, so its setData runs after request 2's - and the page shows user 1's data while the UI says user 2. Nothing errors; it is just wrong.

    Promises resolve in the order the network returns them, not the order you started them. Any code path where a dependency can change while a request is open has this bug, and it shows up most in search-as-you-type, where every keystroke starts one.

    Follow-up How often does it actually happen?

    Constantly on a slow or lossy connection, and almost never on your laptop against localhost - which is why it reaches production. It is also why the fix belongs in the code by default, not added after a bug report.

  7. 07Give me both fixes for the race.

    One: abort the previous request in the cleanup with an AbortController, so the loser's promise rejects and never reaches setState. Two: an ignore flag - let ignore = false at the top of the effect, return () => { ignore = true } as cleanup, and if (ignore) return before every setState.

    They fix it in different places. The abort stops the request; the flag lets the request finish and discards the result. The flag works because each run of the effect creates its own ignore variable, and its cleanup closes over that one - so the stale run's flag is set while the live run's is not.

    Follow-up Which do you use?

    The abort, because it also stops the network work and frees the connection. The flag is the fallback for anything you cannot cancel - a third-party SDK, a promise from a library with no signal support - and using both together is common and correct.

  8. 08Why is fetching in an effect described as the manual version of React Query?

    Because every feature you end up adding - don't refetch if it's fresh, share one request between two components, refetch when the window regains focus, retry on failure, keep the old data visible while revalidating, invalidate after a mutation - is a cache feature. Once you have all of them you have written a worse React Query.

    The honest framing for an interview: useEffect + fetch is correct and fine for one or two endpoints, and becomes a liability the moment the same data is needed in two places. The library is not there for the fetch call; it is there for the identity, the sharing and the invalidation.

  9. 09Client state versus server state - what is the distinction?

    Client state is owned by this tab and is never out of date: which modal is open, form values, the selected tab. Server state is a cached copy of data that lives elsewhere, can be changed by other people, and needs invalidating and refetching. Different problems, different tools.

    Most 'we need Redux' conversations turn out to be 'most of our global state is server state', and the moment you move that into a data-fetching library the remaining client state is small enough for useState and context. Saying that out loud reframes the whole state-management question.

  10. 10Compare Context, Redux Toolkit, Zustand and React Query. When is each right?

    Context: passing a rarely-changing value down a deep tree - theme, current user, locale. Redux Toolkit: large app, lots of interdependent client state, a team that wants strict conventions, devtools and time travel. Zustand: the same job with far less ceremony - a hook-shaped store, selectors, no provider. React Query or SWR: anything that came from a server.

    The mistakes each one is guarding against are different. Context has no selectors, so every consumer re-renders when the provider's value changes - it is a transport, not a store. Redux is a real store with middleware and devtools, and the criticism it earned was boilerplate, which Toolkit largely fixed. Zustand's pitch is that most apps needed a store, not a framework. And none of the three does caching or invalidation, which is what most 'global state' actually needed.

    Follow-up So do you still need Redux if you have React Query?

    Usually much less of it. Once server data is in the query cache, what's left is genuine client state - and that is often small enough for context plus useState, or one Zustand store.

  11. 11React Query or SWR?

    Same model, different sizes. SWR is smaller and simpler and does stale-while-revalidate beautifully; React Query has more of the machinery - mutations, invalidation, infinite queries, optimistic update helpers, devtools. Reach for SWR on a read-heavy app, React Query once mutations matter.

  12. 12What does caching mean here, and what is a cache key?

    A key identifies a request - the endpoint plus its parameters, like ['users', { page, query }]. Data is stored under it; two components asking for the same key share one request and one result, and a changed key is simply a different entry.

    The failure mode to name is a key that does not include something the request depends on: paginate without the page in the key and every page reads the cache entry for page one. The mirror failure is a key that includes something unstable - a new object identity every render - so nothing ever hits.

  13. 13Why refetch on window focus?

    Because a tab left open for an hour is showing an hour-old copy, and the moment the user comes back is the moment they are about to act on it. Refetching on focus makes stale data self-correcting without a refresh button.

    It is the cheapest possible freshness policy: no polling, no sockets, and it costs a request only when someone is actually looking. Turn it off for data that is expensive to fetch or that genuinely cannot change, and for anything the user is mid-edit on.

  14. 14Explain stale-while-revalidate in one breath.

    Render the cached value immediately, fetch in the background, replace it when the new one arrives. The user never sees a spinner for data you already have, and the data is never more than one round trip out of date.

    The trade is that the user can act on a stale value for a second or two. That is right for a list of articles and wrong for an account balance - which is the judgement half of the answer.

  15. 15How do you paginate?

    Offset-based - ?page=3&limit=20 - is trivial and fine for small, stable lists. Cursor-based - ?after=<id> - is what you want for anything long or changing, because it does not skip or duplicate rows when items are inserted while the user is reading.

    The offset bug is concrete: you read page one, someone inserts a row at the top, and page two now starts with the row that was last on page one. In React terms the other half of the answer is the cache key - the page number must be in it, and keeping the previous page's data visible while the next loads is what stops the layout jumping.

  16. 16How do you build infinite scroll?

    Keep an array of pages and a cursor for the next one, and load the next page when a sentinel element at the bottom of the list scrolls into view - IntersectionObserver in an effect, with the observer disconnected in the cleanup.

    IntersectionObserver rather than a scroll handler because the browser does the work off the main thread and there is nothing to throttle. Guard on 'already loading' and 'no more pages' or a fast scroll fires the loader several times for the same page. And say the accessibility cost out loud: infinite scroll makes the footer unreachable and the back button lossy, which is why a 'load more' button is often the better answer.

  17. 17What is an optimistic update?

    Applying the change to the UI before the server confirms it - the like button fills in instantly - while the request runs. On failure you roll the UI back and tell the user.

    You need three things or it is a liability: a snapshot of the previous value to restore, cancellation of any in-flight refetch that would overwrite your optimistic value with the old server one, and a visible rollback so the user is never left believing something that did not happen. Do it for cheap, reversible, high-frequency actions - likes, checkboxes, reordering. Do not do it for a payment.

  18. 18How should retries work?

    Retry a couple of times with exponential backoff and jitter - 1s, 2s, 4s - and only for things that might succeed on a second try: network errors, timeouts, 502/503/504. Never retry a 400, a 401 or a 404, and never retry a non-idempotent POST blindly.

    Retrying a 400 is asking the same wrong question louder. Jitter matters because without it every client that failed during an outage retries in lockstep and hits the recovering server as a single wave - that is the thundering herd, and backoff alone does not prevent it.

  19. 19Spinner or skeleton?

    A skeleton for anything with a known shape, because it reserves the layout and the page does not jump when the data lands. A spinner for a short indeterminate wait. And delay either by 200-300ms so a fast response never flashes one at all.

    The flash is the specific bug: showing a spinner for 80ms is worse than showing nothing, because the eye registers the flicker as jank. React 18's useTransition and isPending are the built-in way to keep the old content on screen instead of a fallback.

  20. 20What is a request waterfall and how do you avoid it?

    Each request has to wait for the one before it - component A fetches the user, renders B, which fetches the user's posts. The total time is the sum instead of the max. Avoid it by starting independent requests together with Promise.all, or by hoisting the fetch to a route loader or the server.

    Waterfalls come from data requirements being discovered during render, which is why route-level loaders and server components attack the problem at the level it actually exists. Inside one component the fix is smaller: fire both requests, then await both.

    Follow-up Promise.all or Promise.allSettled?

    all rejects as soon as any one does and you lose the results that succeeded. Use allSettled when the page can render usefully with one section missing.

  21. 21Where should the fetch live - the component, a hook, a route loader, the server?

    Never inline in the component body of a big component. A custom hook at minimum, so the component reads as UI. A route loader or a server component when you want the request to start before the component renders at all.

    The hook is about readability and reuse; moving it to a loader or the server is about latency, because it removes a round trip that could not start until JavaScript had loaded and rendered. Those are different wins and it is worth saying which one you are buying.

  22. 22What is React Suspense for data fetching?

    A component throws a promise while its data is unavailable, and the nearest <Suspense fallback={...}> above it renders the fallback until it resolves. It moves the loading state out of the component and into the tree.

    Combined with an error boundary it removes both branches from the component: no if (loading), no if (error), just the success render. In React 19 the use() hook makes reading a promise in render a first-class thing. You still need a library or a framework to give you a suspending resource - writing one by hand is not the answer to give.

  23. 23How do you deduplicate two components asking for the same data?

    By cache key. A library keys the in-flight promise so the second caller joins the first instead of starting another. By hand you would have to hoist the fetch to a common parent, or keep a module-level map of key to promise.

    This is the strongest single argument for a data library in a real app: it is why two widgets both showing the current user cause one request, and there is no way to get it from useEffect in each component without inventing a registry.

  24. 24Why does my fetch fire twice on mount?

    Strict Mode in development remounts every component once, so effects run setup, cleanup, setup. It does not happen in production. The correct response is to abort in the cleanup, not to add a guard ref.

    The double fetch is a test result, not a bug in itself: it is telling you what happens whenever this component remounts, which real navigation does all the time. A hasFetched ref suppresses the symptom and leaves the missing cancellation in place.

  25. 25Does setting state after unmount still warn?

    No - React 18 removed that warning, because it was firing on correct code and pushing people towards isMounted flags. It is still worth cancelling, just for the network and the wasted work, not to silence a warning.

    The warning claimed a memory leak, and mostly there wasn't one. The real reason to clean up is the race, and that reason is unchanged.

  26. 26How do you handle errors that should not be per-component?

    An error boundary above the route for unexpected failures, and in-component error state for expected ones - a 404 on a record the user asked for is content, not a crash.

    Boundaries are class components with getDerivedStateFromError and componentDidCatch, and they do not catch errors thrown in event handlers or in async callbacks - only during rendering, in lifecycles and in constructors below them. So a rejected fetch inside an effect never reaches one unless you re-throw it during render, which is precisely what Suspense-based libraries do for you.

  27. 27How would you poll, and when would you not?

    An interval in an effect, cleared in the cleanup, and paused when the tab is hidden. Do not poll when the data changes rarely - refetch on focus and after mutations instead - and do not poll when updates need to be immediate and frequent, which is what a WebSocket or server-sent events are for.

    Polling is the right answer far more often than people admit: it is trivially cacheable, survives disconnects for free, and has no server-side connection state. The cost is a request per client per interval whether anything changed or not.

  28. 28How do you test a component that fetches?

    Intercept at the network layer with something like MSW rather than mocking fetch itself, then assert on what the user sees: loading, then the rows, or the error message. Assert the error path as deliberately as the success one.

    Mocking the fetch global couples the test to how the request is made rather than what was requested, so a switch to axios breaks every test even though nothing observable changed. Intercepting the request tests the contract instead.

  29. 29What is the ideal useFetch return shape?

    { data, error, loading, refetch }, or a single status string plus data and error. The important part is that data and error are cleared when a new request starts, and that refetch exists.

    Returning [data, loading] as a tuple looks like useState and reads badly at the call site, because the caller has to remember the order. An object is self-documenting and extensible.

  30. 30How do you keep the old data on screen during a refetch?

    Keep data and add a separate isFetching flag rather than clearing data and flipping back to loading. React Query calls this keeping previous data; React 18's useTransition does it at the render level.

    The difference is visible on pagination: clear the data and the table collapses to a spinner and the page jumps; keep it and the old rows dim slightly until the new ones arrive. Say which one you would pick and why - this is a taste question as much as a technical one.

  31. 31What headers and options actually matter on a fetch?

    method, headers with Content-Type: application/json on a JSON body, body: JSON.stringify(...), signal for the abort, and credentials: 'include' if the API needs cookies across origins.

    credentials is the one that bites: by default fetch sends cookies only to the same origin, so a cross-origin session just silently is not authenticated. And the server must answer with an explicit origin plus Access-Control-Allow-Credentials, because a wildcard is not allowed with credentials.

  32. 32The interviewer says: our search-as-you-type sends a request per keystroke and sometimes shows the wrong results. Fix it.

    Three things, in order. Debounce the term so a request only goes out once typing pauses. Abort the previous request in the effect cleanup so a stale response cannot land. And ignore AbortError so the cancelled requests do not render as failures.

    That is the whole answer and it is one effect with a setTimeout and an AbortController in it, sharing a single cleanup. Debouncing alone reduces the number of races but does not eliminate them - two requests can still overlap on a slow connection - which is why the abort is not optional.

Predict the output

The output is hidden. Predict it before you open it.

fetch and the 404

try {
  const res = await fetch("/api/missing"); // the server replies 404
  console.log("resolved", res.ok, res.status);
} catch (err) {
  console.log("caught", err.message);
} finally {
  console.log("finally");
}
Show the output
resolved false 404
finally

The catch block never runs. A 404 is a successful HTTP exchange, so the promise resolves and only res.ok knows anything is wrong. Without an explicit if (!res.ok) throw, the next line parses an error page as JSON and the user sees a syntax error.

The race, in four lines

const first = new Promise((r) => setTimeout(() => r("user 1"), 300));
const second = new Promise((r) => setTimeout(() => r("user 2"), 100));

first.then((v) => console.log("setData:", v));
second.then((v) => console.log("setData:", v));
Show the output
setData: user 2
setData: user 1

Started first, resolved last. The final setData wins, so the screen ends up showing user 1 while the UI says user 2. Nothing threw and nothing was slow enough to notice locally - this is the entire bug.

An async effect callback

useEffect(async () => {
  const res = await fetch(url);
  setData(await res.json());
}, [url]);
Show the output
Warning: useEffect must not return anything besides a function, which is used for clean-up.

An async function returns a promise, and React treats an effect's return value as the cleanup. So there is no cleanup at all - no abort, no ignore flag - which means the warning is really telling you the race condition is unhandled. Declare the async function inside the effect and call it.

What abort actually rejects with

const controller = new AbortController();

fetch("/api/slow", { signal: controller.signal })
  .then(() => console.log("done"))
  .catch((err) => console.log(err.name, err instanceof DOMException));

controller.abort();
Show the output
AbortError true

The promise rejects with a DOMException named AbortError, and it lands in your catch alongside real failures. Check err.name === "AbortError" and return early, or every cancelled request renders as an error message the user did nothing to deserve.

Reading a response twice

const res = await fetch("/api/thing");
const a = await res.json();
const b = await res.json();
Show the output
TypeError: Failed to execute 'json' on 'Response': body stream already read

A response body is a one-shot stream. This bites when a logging wrapper reads the body before the caller does. res.clone() before the first read gives you a second readable copy.

all versus allSettled

const ok = Promise.resolve("a");
const bad = Promise.reject(new Error("boom"));
const slow = new Promise((r) => setTimeout(() => r("c"), 100));

Promise.all([ok, bad, slow])
  .then((v) => console.log("all", v))
  .catch((e) => console.log("all failed:", e.message));

Promise.allSettled([ok, bad, slow]).then((rs) =>
  console.log(rs.map((r) => r.status))
);
Show the output
all failed: boom
['fulfilled', 'rejected', 'fulfilled']

all rejects the instant one input does, discarding the two that succeeded - and it logs first because it did not wait for the slow one. allSettled waits for everything and reports each outcome, which is what you want when the page can render with one section missing.

map with an async callback

const ids = [1, 2, 3];
const results = ids.map(async (id) => id * 2);

console.log(results);
console.log(await Promise.all(results));
Show the output
[Promise, Promise, Promise]
[2, 4, 6]

map does not await anything - an async callback returns a promise, so you get an array of promises. Rendering that array gives you three empty rows. Promise.all around it is the fix, and it also runs the three requests in parallel rather than in a waterfall.

Two async functions, interleaved

async function a() {
  console.log("A start");
  await null;
  console.log("A end");
}
async function b() {
  console.log("B start");
  await null;
  console.log("B end");
}

a();
b();
console.log("sync");
Show the output
A start
B start
sync
A end
B end

Everything before the first await runs synchronously; everything after is queued as a microtask. So both functions start, the synchronous line runs, and only then do the continuations drain - in the order they were queued. This is why firing two fetches and awaiting both later is parallel, while awaiting each in turn is a waterfall.

The ignore flag, without React

let ignore = false;
const cleanup = () => {
  ignore = true;
};

setTimeout(() => console.log("ignore is", ignore), 0);
cleanup();
Show the output
ignore is true

The callback closes over the variable, not a copy of its value, so it sees the assignment the cleanup made after it was scheduled. That is the entire mechanism behind the let ignore = false fix - each run of the effect gets its own variable, and only the stale run's cleanup has fired.

An empty body

const res = new Response("", { status: 204 });

res.json().catch((e) => console.log(e.constructor.name, e.message));
Show the output
SyntaxError Unexpected end of JSON input

A 204 No Content has no body, and json() on nothing is a parse error. Any endpoint that can return 204 - most DELETEs - needs a status check before the parse, not just an res.ok check.

A cache key that is not a cache key

console.log(JSON.stringify({ page: 1, q: "a" }));
console.log(JSON.stringify({ q: "a", page: 1 }));
console.log(
  JSON.stringify({ page: 1, q: "a" }) === JSON.stringify({ q: "a", page: 1 })
);
Show the output
{"page":1,"q":"a"}
{"q":"a","page":1}
false

JSON.stringify preserves insertion order, so two identical parameter sets built in different orders produce different keys - two cache entries and two requests for the same data. A key built from a sorted array, ['users', page, q], does not have this problem.

Exponential backoff with a ceiling

for (let attempt = 0; attempt < 5; attempt++) {
  const delay = Math.min(1000 * 2 ** attempt, 8000);
  console.log(attempt, delay);
}
Show the output
0 1000
1 2000
2 4000
3 8000
4 8000

Doubling with a cap, which is the retry schedule to describe out loud. The missing piece here is jitter - add a random fraction to each delay, or every client that failed during an outage retries in lockstep and arrives as one wave.

Which effect cleanup runs, and when

useEffect(() => {
  console.log("start request for", id);
  return () => console.log("cancel request for", id);
}, [id]);

// mounted with id=1, then id changes to 2, then the component unmounts
Show the output
start request for 1
cancel request for 1
start request for 2
cancel request for 2

The cleanup for the old id runs before the new setup, and it closes over the OLD value - which is precisely why putting controller.abort() there cancels the right request. In development Strict Mode there is an extra start/cancel pair for id 1 at the beginning.

Write it from memory

Complete, runnable code. Practice typing it from memory before your interview.

Fetch in an effect - all four states, with the race fixed

The component an interviewer asks for when they say "fetch a user and show it". Idle, loading, error and success are all real branches; res.ok is checked; the previous request is aborted; AbortError is swallowed; and there is a retry.

import { useState, useEffect } from "react";

export default function UserCard({ userId }) {
  const [status, setStatus] = useState("idle");
  const [user, setUser] = useState(null);
  const [error, setError] = useState(null);
  const [attempt, setAttempt] = useState(0);

  useEffect(() => {
    if (!userId) {
      setStatus("idle");
      setUser(null);
      setError(null);
      return;
    }

    const controller = new AbortController();
    setStatus("loading");
    setUser(null);
    setError(null);

    async function load() {
      try {
        const res = await fetch("/api/users/" + userId, {
          signal: controller.signal,
        });
        if (!res.ok) {
          throw new Error("Could not load that profile (" + res.status + ")");
        }
        const json = await res.json();
        setUser(json);
        setStatus("success");
      } catch (err) {
        if (err.name === "AbortError") return;
        setError(err);
        setStatus("error");
      }
    }

    load();

    return () => controller.abort();
  }, [userId, attempt]);

  if (status === "idle") {
    return <p>Choose someone to see their profile.</p>;
  }

  if (status === "loading") {
    return <p role="status">Loading profile…</p>;
  }

  if (status === "error") {
    return (
      <div role="alert">
        <p>{error.message}</p>
        <button onClick={() => setAttempt((n) => n + 1)}>Try again</button>
      </div>
    );
  }

  return (
    <article>
      <h2>{user.name}</h2>
      <p>{user.title}</p>
    </article>
  );
}
  • One status string, not three booleans - loading and error cannot both be true, so no render has to guard an impossible combination.
  • setUser(null) at the start of a new request stops last user's data sitting under the new user's name for a second.
  • attempt is in the dependency array purely so the retry button re-runs the effect. It is the smallest correct way to re-trigger an effect on demand.
  • The abort is the race fix. Change userId mid-flight and the old request's promise rejects with AbortError before it can call setUser.
  • if (err.name === "AbortError") return must come before setting the error state, or every fast click renders as a failure.
  • In development this runs twice under Strict Mode. That is the cleanup being tested, and it passes - the first request is aborted.

useFetch with refetch - the ignore-flag version

The same job as an abort, done with the other fix, plus a refetch the caller can trigger. Worth knowing both: the flag is what you use when whatever you are calling has no way to be cancelled.

import { useState, useEffect, useCallback } from "react";

export function useFetch(url) {
  const [state, setState] = useState({
    data: null,
    error: null,
    loading: Boolean(url),
  });
  const [nonce, setNonce] = useState(0);

  const refetch = useCallback(() => setNonce((n) => n + 1), []);

  useEffect(() => {
    if (!url) {
      setState({ data: null, error: null, loading: false });
      return;
    }

    let ignore = false;
    setState({ data: null, error: null, loading: true });

    async function load() {
      try {
        const res = await fetch(url);
        if (!res.ok) throw new Error("Request failed (" + res.status + ")");
        const json = await res.json();
        if (ignore) return;
        setState({ data: json, error: null, loading: false });
      } catch (err) {
        if (ignore) return;
        setState({ data: null, error: err, loading: false });
      }
    }

    load();

    return () => {
      ignore = true;
    };
  }, [url, nonce]);

  return { ...state, refetch };
}

// Using it
export function Repos({ owner }) {
  const { data, error, loading, refetch } = useFetch(
    owner ? "/api/owners/" + owner + "/repos" : null
  );

  if (!owner) return <p>Pick an owner.</p>;
  if (loading) return <p role="status">Loading…</p>;
  if (error) {
    return (
      <div role="alert">
        <p>{error.message}</p>
        <button onClick={refetch}>Try again</button>
      </div>
    );
  }

  return (
    <ul>
      {data.map((repo) => (
        <li key={repo.id}>{repo.name}</li>
      ))}
    </ul>
  );
}
  • Each run of the effect declares its own ignore. The cleanup closes over that run's variable, so setting it to true only silences the run that is being replaced.
  • The checks go immediately before every setState, and after every await - a check placed before the await proves nothing, because the dependency can change during it.
  • Passing null as the url is how the caller expresses idle. The hook has to handle it explicitly or it fetches /null.
  • nonce in the dependency array is the refetch mechanism. refetch is wrapped in useCallback so it is safe to pass to a memoised child or use as another hook's dependency.
  • This version lets the request finish and throws the result away. Prefer aborting when you can; use this when you cannot.

Debounced search that cancels what is in flight

The live-coding favourite, because it needs three separate things right at once: the input stays controlled and instant, requests only go out when typing pauses, and a slow earlier response can never overwrite a fast later one. One effect, one cleanup, both timers and requests torn down together.

import { useState, useEffect } from "react";

export default function SearchUsers() {
  const [term, setTerm] = useState("");
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    const query = term.trim();

    if (!query) {
      setResults([]);
      setLoading(false);
      setError(null);
      return;
    }

    const controller = new AbortController();
    setLoading(true);
    setError(null);

    const timer = setTimeout(async () => {
      try {
        const res = await fetch(
          "/api/search?q=" + encodeURIComponent(query),
          { signal: controller.signal }
        );
        if (!res.ok) throw new Error("Search failed (" + res.status + ")");
        const json = await res.json();
        setResults(json.items);
        setLoading(false);
      } catch (err) {
        if (err.name === "AbortError") return;
        setError(err);
        setResults([]);
        setLoading(false);
      }
    }, 300);

    return () => {
      clearTimeout(timer);
      controller.abort();
    };
  }, [term]);

  return (
    <div>
      <label htmlFor="q">Search</label>
      <input
        id="q"
        value={term}
        onChange={(e) => setTerm(e.target.value)}
        autoComplete="off"
      />

      {loading && <p role="status">Searching…</p>}
      {error && <p role="alert">{error.message}</p>}

      {!loading && !error && term.trim() && results.length === 0 && (
        <p>No matches for “{term}”.</p>
      )}

      <ul>
        {results.map((user) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}
  • One cleanup does both jobs. clearTimeout kills a request that has not started; abort kills one that has. Every keystroke runs it, so at most one request is ever alive.
  • The input is bound to term, never to a debounced copy - typing has to feel instant. Only the request is delayed.
  • The empty-query branch returns early and clears the results, so backspacing to nothing does not leave the previous matches on screen.
  • encodeURIComponent is not optional: a term with an ampersand or a space silently produces a different query.
  • Debouncing alone does not fix the race. Two requests can still overlap when one response is slow, which is why the abort has to be there as well.
  • Extract the timer into useDebounce and this becomes a plain fetch effect on the debounced value - the same code, split at the seam.
← Forms: controlled, uncontrolled and validatedPassing data, context and routing →