How do you load async data in React?
By FrontendPro Editorial Team Updated 9/26/2026
Answer
The honest answer for 2026: don't hand-roll it. Use a query library on the client, or fetch on the server in a Server Component. Writing useEffect + fetch yourself means reimplementing caching, deduplication, cancellation and race handling, and most hand-written versions get at least one of those wrong.
What the manual version has to handle
function User({ id }) {
const [state, setState] = useState({ loading: true });
useEffect(() => {
const ac = new AbortController();
setState({ loading: true });
fetch(`/api/users/${id}`, { signal: ac.signal })
.then(r => { if (!r.ok) throw new Error(r.status); return r.json(); })
.then(data => setState({ data }))
.catch(e => { if (e.name !== 'AbortError') setState({ error: e }); });
return () => ac.abort(); // the important line
}, [id]);
if (state.loading) return <Spinner />;
if (state.error) return <Error error={state.error} />;
return <Profile user={state.data} />;
}
Note the three things people forget: fetch does not reject on a 404, the cleanup must abort so a slow first request cannot overwrite a fast second one, and resetting to loading when id changes.
With a query library
const { data, isPending, error } = useQuery({
queryKey: ['user', id],
queryFn: ({ signal }) => fetch(`/api/users/${id}`, { signal }).then(r => r.json()),
});
One hook, and you get caching keyed by ['user', id], request deduplication across components, background refetch, retry, stale-while-revalidate, and cancellation.
On the server
A Server Component just awaits, and the loading state becomes a <Suspense> boundary:
async function Page({ params }) {
const user = await getUser(params.id); // no state, no effect
return <Profile user={user} />;
}
<Suspense fallback={<Skeleton />}><Page /></Suspense>
This removes the client-side loading state entirely, and the data never travels as JSON to be re-parsed.
Things to get right whichever route you take
- Fetch in parallel. Two sequential
awaits that do not depend on each other cost twice the latency.Promise.allthem. - Avoid the request waterfall. A parent that renders a child which then fetches serialises two round trips. Hoist the fetch or preload it.
- Show a skeleton, not a spinner, sized like the content — it keeps CLS at zero.
- Handle the empty case separately from loading and error. "No results" is not a failure.
- For mutations, invalidate the affected queries rather than patching local copies, unless you are deliberately doing an optimistic update with a rollback.
