Topics in this subject
React 4 min read Updated 5 Aug 2026

Data Fetching

Fetching in useEffect, avoiding race conditions, handling states, and why React Query/SWR win.

The baseline: useEffect + fetch#

The classic client pattern: fetch in an effect keyed by the inputs, track loading/error/data.

function User({ id }) {
  const [state, setState] = useState({ status: "loading", data: null, error: null });

  useEffect(() => {
    let ignore = false;                       // 🟢 stale-response guard
    const ctrl = new AbortController();

    setState({ status: "loading", data: null, error: null });
    fetch(`/api/users/${id}`, { signal: ctrl.signal })
      .then((r) => { if (!r.ok) throw new Error(r.status); return r.json(); })
      .then((data) => { if (!ignore) setState({ status: "success", data, error: null }); })
      .catch((error) => { if (!ignore && error.name !== "AbortError")
                            setState({ status: "error", data: null, error }); });

    return () => { ignore = true; ctrl.abort(); };  // cleanup on id change/unmount
  }, [id]);

  // ...render on state.status
}

⚠️ Race conditions#

If id changes quickly, request A and request B are in flight together; A may resolve after B and overwrite the correct data with stale results. React does not cancel or order these for you. Two defenses, both in cleanup:

Technique What it does
ignore flag Ignore the resolved promise of a superseded effect (prevents stale setState)
AbortController Actually cancel the in-flight HTTP request

The cleanup function runs before the next effect and on unmount — that's where you flip ignore and call ctrl.abort(). Ignoring stale responses also prevents setState-after-unmount work.

Handle all four states#

if (status === "loading") return <Spinner />;
if (status === "error")   return <Error err={error} />;
if (!data?.length)        return <Empty />;   // don't forget empty!
return <List items={data} />;

Why libraries win 🎯#

Hand-rolled fetching re-implements caching, dedup, retries, and revalidation on every screen — badly. React Query (TanStack Query) and SWR give you all of it declaratively:

const { data, isLoading, error } = useQuery({
  queryKey: ["user", id],
  queryFn: ({ signal }) => fetch(`/api/users/${id}`, { signal }).then((r) => r.json()),
});
Concern Manual useEffect React Query / SWR
Caching by key You build it Built-in
Request dedup No Yes
Revalidate on focus/reconnect No Yes
Race conditions You guard manually Handled by query key
Retries, pagination, mutations Manual Built-in

Suspense & server components#

React Query offers useSuspenseQuery to drive <Suspense> fallbacks and error boundaries instead of isLoading branches. In React Server Components (Next.js App Router) you can await data directly in an async server component — no effect, no client state, no waterfall shipped to the browser (see the RSC/Server Components note).

flowchart TD
  A["Component mounts / id changes"] --> B["Effect runs: setLoading + fetch"]
  B --> C{"id changes again?"}
  C -->|"Yes"| D["Cleanup: ignore=true, ctrl.abort()"]
  D --> E["Stale response discarded"]
  C -->|"No"| F["Response resolves"]
  F --> G["setState(success/error) if not ignored"]

Interview Q&A#

Q1. How does a race condition arise with useEffect fetching? Rapidly changing inputs launch overlapping requests. Responses can resolve out of order, so an older request overwrites newer data. React doesn't sequence them — you must discard superseded responses in the effect cleanup.

Q2. How do you prevent stale responses from being applied? Use a local ignore/didCancel flag flipped to true in the cleanup function, and/or an AbortController whose abort() you call in cleanup. The flag blocks the setState; the controller cancels the actual network request.

Q3. Why prefer React Query or SWR over rolling your own? They provide caching keyed by query, request deduplication, background revalidation (on focus/reconnect), retries, pagination, and mutation handling — plus they solve race conditions via query keys. Manual effects re-implement all of this per component and usually miss cases.

Q4. Which UI states must data fetching handle? At minimum loading, error, and success — plus the often-forgotten empty state (successful response with no items). Robust UIs also handle refetching/stale-while-revalidate indicators.

Q5. How do server components change the fetching story? An async server component can await data on the server and render HTML directly, so no fetching effect, loading state, or data library ships to the client for that data. Client-side libraries remain for interactive, client-owned, or frequently revalidated data.