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.