Fetching Data in an Effect

A request is a side effect with a lifetime, so it fits the setup-and-cleanup shape: start the request in the setup, abort it in the cleanup. Because the setup itself cannot be async, declare the async work inside and call it.

Loading a product, with loading, error and abort handlingJavaScript
function Product({ id }) {
  const [state, setState] = useState({ status: 'loading' });
  useEffect(() => {
    const ctrl = new AbortController();
    setState({ status: 'loading' });
    (async () => {
      try {
        const res = await fetch(`https://api.example.com/products/${id}`,
                                { signal: ctrl.signal });
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        setState({ status: 'ready', product: await res.json() });
      } catch (err) {
        if (err.name !== 'AbortError') setState({ status: 'error', err });
      }
    })();
    return () => ctrl.abort();        // the user navigated away or id changed
  }, [id]);
  if (state.status === 'loading') return <p>Loading...</p>;
  if (state.status === 'error') return <p role="alert">{state.err.message}</p>;
  return <h2>{state.product.name}</h2>;
}

Three states — loading, error, ready — are the minimum. A component that tracks only data shows a blank screen on failure and cannot tell "no results" from "not finished".

The pattern is correct but limited. The request starts only after the component renders, so a parent and child that both fetch create a waterfall; nothing is cached, so going back refetches; and two components asking for the same thing duplicate the work. Production applications move fetching elsewhere: a route loader (Loaders), a Next.js 10,514 server component (Next.js), or a cache library such as TanStack Query 5.103.1 79,069 (npm 2,036 i @tanstack/react-query), which keeps these mechanics and adds deduplication, caching, retries and revalidation. Express.js builds the Express 24,430 endpoints these examples call.