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.
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.