Quiz

How do you handle asynchronous data loading in React applications?

Topics
ReactAsync

TL;DR

Prefer a framework's data-loading APIs when they can coordinate route requests before rendering, or a client cache such as TanStack Query, SWR, or RTK Query when browser-owned data needs caching and synchronization. Exact deduplication, retries, and refetch behavior depend on the tool and configuration. Fetching in an Effect remains valid for simple client-only cases, but it needs explicit loading/error handling, cleanup, and stale-response protection. React 19's use API can read a cached promise and suspend; the promise should come from a Suspense-enabled framework/cache, a route loader, or a Server Component rather than being recreated during render.


Handling asynchronous data loading in React

The right loading layer depends on where the data is needed, who owns its cache, and whether it participates in server rendering or client interactions.

Prefer a framework or data-fetching library for production applications

The React docs recommend considering a framework's built-in data fetching or a client-side cache because those layers can handle caching, deduplication, refetching, race conditions, retries, and loading/error states. Fetching in an Effect is still supported when those alternatives do not fit.

TanStack Query is one popular option 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.

Depending on the library and its configuration, these tools can provide:

  • A cache keyed by query parameters.
  • Deduplication of matching in-flight requests.
  • Configurable refetching on focus, reconnect, or an interval.
  • AbortSignal integration and configurable retries.
  • Primitives for pagination, infinite queries, and optimistic updates.

Server Components and framework loaders

When data is needed for the initial route and does not depend on browser-only state, a framework's server loader can avoid a client request waterfall and keep fetching code out of the client bundle. Client caches remain appropriate for highly interactive, frequently refreshed, or browser-specific data.

  • Next.js App Router (React Server Components)async Server Components can await data directly and stream the result.
  • React Router / Remix loaders — co-locate a loader with the route; data is fetched in parallel with code.
// Next.js Server Component (no useEffect needed)
async function UserPage({ params }) {
const { id } = await params;
const response = await fetch(`https://api.example.com/users/${id}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const user = await response.json();
return <h1>{user.name}</h1>;
}

React 19: use() + <Suspense>

React 19's use() API lets a Client Component read a cached 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. Create or cache the promise outside the rendering Client Component—often in a route loader, Suspense-enabled data source, or Server Component—and pass it down so the same promise is reused across render attempts.

'use client';
import { use, Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
function UserName({ userPromise }) {
const user = use(userPromise); // suspends until resolved
return <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 an interaction changes a Suspense-enabled result, marking the state update as a Transition can keep already revealed content visible and interactive while the next result loads. useDeferredValue provides a lagging value that can let urgent input updates render before slower derived content. Neither API fetches or caches data by itself.

const [isPending, startTransition] = useTransition();
function onTabChange(next) {
startTransition(() => setTab(next));
}

Low-level: useEffect + fetch (when and how)

Plain useEffect + fetch is a valid fallback for a one-off client request. A complete implementation must handle two details that short examples commonly omit: 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 {
// Do not let an aborted, stale request clear a newer request's loading state.
if (!controller.signal.aborted) setLoading(false);
}
})();
// Cancel the in-flight request if `id` changes or the component unmounts
return () => 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 alone does not provide cross-component caching, request deduplication, or refetch-on-focus. Add those capabilities only when the application needs them, usually through a framework loader or data library.

Summary of options

Use this table as a starting point, then account for the framework and freshness requirements of the specific data:

OptionUse when
TanStack Query / SWR / RTK QueryShared client-side data that benefits from caching and synchronization
Server Components / route loadersYou control the framework (Next.js, Remix)
use(promise) + <Suspense>Streaming a promise from a parent in React 19+
useEffect + fetchSimple client-only fetches where you handle cleanup and races

Further reading

Exercises

Check your understanding
Beta
Check your understanding Exercise 1 of 2
Check your understanding Exercise 1 of 2

A client-only search Effect fetches on every query change. A slow response for an old query sometimes replaces a newer result. What directly addresses the race?