Data fetching patterns

React · Practical · 16 min

Every real app loads data; naive fetching causes race conditions, waterfalls and duplicated loading logic.

The basic client-side pattern fetches in an effect, keyed by the inputs:

useEffect(() => {
  let ignore = false;
  fetchUser(id).then(u => { if (!ignore) setUser(u); });
  return () => { ignore = true; };
}, [id]);

The ignore flag matters. If id changes quickly, responses can arrive out of order; without the flag, a slow old response overwrites the new one (a race condition). An AbortController is the alternative: abort in the cleanup and ignore the AbortError. You also need loading and error states, which usually means a small state machine (status: "loading" | "success" | "error").

Hand-written effect fetching has real limits: no caching (navigating back refetches), no deduplication, waterfalls when parent and child each fetch after rendering, and nothing on the first server render. In production code, use one of:

  • A data library such as TanStack Query or SWR: useQuery({ queryKey: ["user", id], queryFn }) gives caching, deduping, background refetch, retries and loading/error state.
  • A framework's data APIs: route loaders in React Router or Server Components in Next.js load data before or during render, on the server, without waterfalls.
  • React 19's `use(promise)` with Suspense: a component can read a promise created by a cache or a parent (or passed from a Server Component), suspending until it resolves. Don't create the promise inside the same component's render; it would be new every time.

Wrap the logic in a custom Hook (useUser(id)) so you can swap implementations later. Mutations (POST/PUT) don't belong in effects at all: run them in event handlers or Actions.

Example

import { useEffect, useState } from "react";

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

  useEffect(() => {
    const controller = new AbortController();
    setState({ status: "loading", data: null, error: null });

    (async () => {
      try {
        const res = await fetch(url, { signal: controller.signal });
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        setState({ status: "success", data: await res.json(), error: null });
      } catch (error) {
        if (error.name !== "AbortError") setState({ status: "error", data: null, error });
      }
    })();

    return () => controller.abort();   // stale requests never win the race
  }, [url]);

  return state;
}

export default function User({ id }) {
  const { status, data, error } = useJson(`https://jsonplaceholder.typicode.com/users/${id}`);
  if (status === "loading") return <p>Loading...</p>;
  if (status === "error") return <p>Failed: {error.message}</p>;
  return <h2>{data.name}</h2>;
}

Remember

  • Fetch in an effect keyed by its inputs, with an ignore flag or AbortController against races
  • Real apps use a data library (TanStack Query, SWR) or framework loaders for caching and no waterfalls
  • With React 19, use(promise) + Suspense reads cached or passed-in promises during render

Watch out for

  • Forgetting cleanup, so a slow earlier response overwrites the data for the current id.
  • Creating a new promise during render and passing it to use(), causing endless suspending.

Check yourself

  1. A profile page fetches in useEffect keyed by userId with no cleanup. The user clicks A then quickly B; A's response arrives last. What's shown?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: react.dev/reference/react/useEffect#fetching-data-with-effects