How do you handle asynchronous data loading in React applications?
TL;DR
In a modern React app, don't roll your own useEffect + fetch for data loading — the React docs explicitly recommend against it. Reach first for a dedicated data-fetching library: TanStack Query, SWR, or RTK Query for client-side fetching, or Server Components and route-level loaders (Next.js App Router, Remix/React Router) when you control the framework. In React 19, the new use() hook lets a component read a promise directly and suspend, which pairs naturally with <Suspense> for loading states and error boundaries for failures. A hand-written useEffect+fetch is a low-level fallback that needs an AbortController, a response.ok check, and careful state handling to avoid race conditions and stale updates.
Handling asynchronous data loading in React
Prefer a data-fetching library (recommended)
The React team's recommendation in react.dev is to use a framework's built-in data fetching or a dedicated library, because they correctly handle caching, deduplication, refetching, race conditions, retries, and loading/error states — all of which are easy to get wrong by hand.
TanStack Query is the de facto standard for client-side fetching:
import { useQuery } from '@tanstack/react-query';function Profile({ userId }) {const { data, isLoading, error } = useQuery({queryKey: ['user', userId],queryFn: async ({ signal }) => {const res = await fetch(`/api/users/${userId}`, { signal });if (!res.ok) throw new Error(`HTTP ${res.status}`);return res.json();},});if (isLoading) return <div>Loading...</div>;if (error) return <div>Error: {error.message}</div>;return <h1>{data.name}</h1>;}
SWR is a lightweight alternative with a similar mental model. RTK Query is the right pick if you already use Redux Toolkit.
These libraries give you for free:
- An in-memory cache keyed by query parameters
- Automatic deduplication of in-flight requests
- Refetching on window focus, network reconnect, and on a stale interval
- Cancellation via
AbortSignal - Retries with backoff
- Pagination, infinite scroll, and optimistic updates
Server Components and framework loaders
If you're using a framework, fetch on the server whenever possible — it's faster (no client waterfall) and ships less JS.
- Next.js App Router (React Server Components) —
asyncServer Components canawaitdata directly and stream the result. - React Router / Remix loaders — co-locate a
loaderwith the route; data is fetched in parallel with code.
// Next.js Server Component (no useEffect needed)async function UserPage({ params }) {const user = await fetch(`https://api.example.com/users/${params.id}`).then((r) => r.json(),);return <h1>{user.name}</h1>;}
React 19: use() + <Suspense>
React 19's use() hook lets a Client Component read a promise. While the promise is pending, the component suspends and the nearest <Suspense> boundary shows the fallback; if the promise rejects, the nearest error boundary catches it. The promise is typically created by a parent (often a Server Component) and passed down as a prop, so the request starts before the child renders.
'use client';import { use, Suspense } from 'react';import { ErrorBoundary } from 'react-error-boundary';function UserName({ userPromise }) {const user = use(userPromise); // suspends until resolvedreturn <h1>{user.name}</h1>;}function Page({ userPromise }) {return (<ErrorBoundary fallback={<div>Failed to load</div>}><Suspense fallback={<div>Loading...</div>}><UserName userPromise={userPromise} /></Suspense></ErrorBoundary>);}
Keeping the UI responsive: useTransition and useDeferredValue
When a user interaction triggers a re-fetch (filtering a list, switching tabs), wrap the state update that causes the new fetch in startTransition so the previous UI stays interactive while the new data loads. useDeferredValue gives you a lagging copy of a value — useful for letting an input stay snappy while a derived list re-renders against slower data.
const [isPending, startTransition] = useTransition();function onTabChange(next) {startTransition(() => setTab(next));}
Low-level: useEffect + fetch (when and how)
Plain useEffect + fetch is a fine fallback for one-off fetches in a small app, or as an escape hatch. But you must handle two things the naive example always gets wrong: race conditions (an older request resolving after a newer one) and HTTP errors (fetch only rejects on network failure — a 500 still resolves).
import { useEffect, useState } from 'react';function User({ id }) {const [data, setData] = useState(null);const [error, setError] = useState(null);const [loading, setLoading] = useState(true);useEffect(() => {const controller = new AbortController();setLoading(true);setError(null);(async () => {try {const res = await fetch(`/api/users/${id}`, {signal: controller.signal,});if (!res.ok) throw new Error(`HTTP ${res.status}`);const json = await res.json();setData(json);} catch (err) {if (err.name !== 'AbortError') setError(err);} finally {setLoading(false);}})();// Cancel the in-flight request if `id` changes or the component unmountsreturn () => controller.abort();}, [id]);if (loading) return <div>Loading...</div>;if (error) return <div>Error: {error.message}</div>;return <h1>{data.name}</h1>;}
Even with these fixes, useEffect does not give you caching across components, deduplication, or refetch-on-focus — which is why a real app outgrows it quickly.
Summary of options
| Option | Use when |
|---|---|
| TanStack Query / SWR / RTK Query | Default for client-side fetching |
| Server Components / route loaders | You control the framework (Next.js, Remix) |
use(promise) + <Suspense> | Streaming a promise from a parent in React 19+ |
useEffect + fetch | One-off fetches, learning, or escape hatch only |