Data fetching in React has historically forced developers to juggle useEffect, useState, and loading state they had to manage by hand. React 19’s use() hook changes this equation—it lets components suspend while waiting for a promise to resolve, delegating loading and error states to Suspense and error boundaries respectively.
Table of Contents
Why Data Fetching in React Has Always Been Painful
Data fetching in React has historically forced developers to juggle useEffect, useState, and loading state they had to manage by hand. A typical fetch pattern demanded at least three state variables (data, isLoading, error), a useEffect call with a cleanup function or boolean flag to guard against race conditions, and explicit checks to avoid updates after the component unmounts. The result was 15 or more lines of boilerplate for what should be a straightforward operation: get data, show data.
React 19’s use() hook changes this equation. It lets components suspend while waiting for a promise to resolve, delegating loading and error states to Suspense and error boundaries respectively. The component itself only contains the logic that matters: reading data and rendering UI.
This article walks through four production-grade data fetching patterns built on use(), from basic Suspense integration through parallel fetching, caching, and user-triggered dynamic loads. Each pattern includes full, working code.
Prerequisites
You need React 19 (react@19.0.0 and react-dom@19.0.0) and react-error-boundary v4+ (npm install react-error-boundary). The article assumes intermediate React knowledge and basic familiarity with async JavaScript. You also need a running API backend providing /api/users/:id, /api/stats/:id, /api/search?q=, and /api/posts?userId= endpoints, or you can mock them using a tool like MSW or a local Express server.
What the use() Hook Actually Does
use() with Promises: Suspending Until Data Resolves
The use() hook accepts a promise and suspends the component until that promise resolves. Once resolved, use() returns the resolved value directly, allowing the component to render with that data as though it were synchronous. If the promise rejects, the rejection propagates to the nearest error boundary.
Unlike other hooks, use() is exempt from the top-level-only rule and can be called conditionally or inside loops. It must still be called inside a React function component or custom hook — it cannot be used in event handlers, class methods, or regular functions.
import { use, useMemo, Suspense } from 'react';
function App() {
const userPromise = useMemo(() => fetchUser(1), []);
return (
<Suspense fallback={<p>Loading profile...p>}>
<UserProfile userPromise={userPromise} />
Suspense>
);
}
function UserProfile({ userPromise }) {
const user = use(userPromise);
return <h1>{user.name}h1>;
}
The parent component creates the promise and passes it as a prop. The child calls use() to unwrap it. React suspends UserProfile until fetchUser resolves, showing the fallback in the meantime.
use() with Context: A Cleaner useContext
The use() hook also reads React context values. Unlike useContext, it can be called inside conditionals.
import { use } from 'react';
function StatusBar({ showTheme }) {
if (showTheme) {
const theme = use(ThemeContext);
return <div style={{ color: theme.primary }}>Themeddiv>;
}
return <div>Defaultdiv>;
}
This is impossible with useContext, which must be called unconditionally at the top level of a component. Context reading is not the primary focus of this article, but use() replaces both useContext and the Suspense-fetch pattern with a single function that works with promises and context objects.
The Critical Rule: Never Create Promises Inside the Render
Creating a promise inside the rendering component causes a Suspense loop. Each render creates a new, always-pending promise. React suspends, retries, gets another new pending promise, and either throws a “too many re-renders” error or oscillates until a timeout, depending on the React version and environment.
function UserProfile({ userId }) {
const user = use(fetch(`/api/users/${userId}`).then(r => r.json()));
return <h1>{user.name}h1>;
}
function App() {
const userPromise = useMemo(() => fetchUser(1), []);
return (
<Suspense fallback={<p>Loading...p>}>
<UserProfile userPromise={userPromise} />
Suspense>
);
}
function UserProfile({ userPromise }) {
const user = use(userPromise);
return <h1>{user.name}h1>;
}
Note: The “RIGHT” example above shows the structural pattern — the promise is created outside the component that calls
use(). TheuseMemoensures the promise is stable across re-renders ofApp. For more robust caching across components, apply Pattern 3 (caching) — e.g.,const userPromise = cachePromise('user-1', () => fetchUser(1));.
The promise must be created outside the component that calls use(), whether in a parent component, a module-scope variable, an event handler, or a cache. This is the single most common mistake developers make with use().
Pattern 1: Simple Fetch with Suspense and Error Boundaries
Setting Up the Suspense Boundary
The consuming component must be wrapped in a boundary that provides a fallback UI while the promise is pending.
First, install the required dependency if you haven’t already:
npm install react-error-boundary
import { use, useMemo, Suspense } from 'react';
function fetchUser(id) {
return fetch(`/api/users/${id}`).then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText || 'Unknown error'}`);
return res.json();
});
}
function UserProfile({ userPromise }) {
const user = use(userPromise);
return (
<div className="profile">
<h2>{user.name}h2>
<p>{user.email}p>
div>
);
}
function App() {
const userPromise = useMemo(() => fetchUser(1), []);
return (
<Suspense fallback={<div className="skeleton">Loading profile...div>}>
<UserProfile userPromise={userPromise} />
Suspense>
);
}
The fetchUser utility returns a plain promise. UserProfile calls use() to unwrap it. React handles the pending state entirely through the Suspense fallback. The useMemo ensures the promise is stable across re-renders.
Adding an Error Boundary for Rejected Promises
When a promise passed to use() rejects, the error propagates upward to the nearest error boundary. Without one, the entire application can crash.
import { use, useMemo, Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
function ErrorFallback({ error, resetErrorBoundary }) {
console.error('[ErrorBoundary]', error);
return (
<div role="alert">
<p>Something went wrong. Please try again.p>
<button onClick={resetErrorBoundary}>Try againbutton>
div>
);
}
function App() {
const userPromise = useMemo(() => fetchUser(1), []);
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<Suspense fallback={<div>Loading profile...div>}>
<UserProfile userPromise={userPromise} />
Suspense>
ErrorBoundary>
);
}
The ErrorBoundary wraps the Suspense boundary. If fetchUser throws or the network request fails, the error fallback renders instead of a white screen. The FallbackComponent prop receives error and resetErrorBoundary, enabling retry behavior. The raw error.message is logged to the console for debugging but not rendered to the user, since server-controlled error details could expose sensitive information.
Why This Pattern Beats useEffect
| Aspect | useEffect + useState | use() + Suspense |
|---|---|---|
| State variables needed | 3 (data, isLoading, error) |
0 |
| Lines of fetch logic | 15+ | 5 |
| Race condition handling | Manual (abort controller or flag) | Stale renders prevented (in-flight requests are not cancelled) |
| Loading UI | Conditional render in component | Declarative Suspense fallback |
| Error handling | try/catch in effect + state | Error boundary |
No manual isLoading toggling. No cleanup functions. No stale closure bugs. The component reads data and renders it; everything else is structural.
The component itself only contains the logic that matters: reading data and rendering UI.
Pattern 2: Parallel Data Fetching (Avoiding Waterfalls)
The Waterfall Problem with Dependent Fetches
When the second fetch depends on the result of the first — for example, fetching posts using a userId returned by fetchUser — a true waterfall occurs. fetchPosts cannot start until fetchUser resolves, and total wait time is t1 + t2.
import { use } from 'react';
function Dashboard({ userPromise, getPostsPromise }) {
const user = use(userPromise);
const posts = use(getPostsPromise(user.id));
return (
<div>
<h1>{user.name}h1>
<ul>{posts.map(p => <li key={p.id}>{p.title}li>)}ul>
div>
);
}
If both promises are initiated simultaneously in the parent and are independent of each other, network I/O runs concurrently. However, when the second fetch depends on data from the first (as above), the waterfall is real. Promise.all or sibling Suspense boundaries eliminate this only when fetches are independent. For dependent fetches, restructure the data requirements or use a single combined endpoint.
Fetching in Parallel with Promise.all and Sibling Components
Strategy A combines independent promises before passing them to use():
import { use, useMemo, Suspense } from 'react';
function Dashboard({ dataPromise }) {
const [user, posts] = use(dataPromise);
return (
<div>
<h1>{user.name}h1>
<ul>{posts.map(p => <li key={p.id}>{p.title}li>)}ul>
div>
);
}
function App() {
const dataPromise = useMemo(() => Promise.all([fetchUser(1), fetchPosts(1)]), []);
return (
<Suspense fallback={<div>Loading dashboard...div>}>
<Dashboard dataPromise={dataPromise} />
Suspense>
);
}
Strategy B splits data consumers into sibling components with independent Suspense boundaries:
import { use, useMemo, Suspense } from 'react';
function UserCard({ userPromise }) {
const user = use(userPromise);
return <h2>{user.name} — {user.email}h2>;
}
function PostsList({ postsPromise }) {
const posts = use(postsPromise);
return <ul>{posts.map(p => <li key={p.id}>{p.title}li>)}ul>;
}
function App() {
const userPromise = useMemo(() => fetchUser(1), []);
const postsPromise = useMemo(() => fetchPosts(1), []);
return (
<div>
<Suspense fallback={<div>Loading user...div>}>
<UserCard userPromise={userPromise} />
Suspense>
<Suspense fallback={<div>Loading posts...div>}>
<PostsList postsPromise={postsPromise} />
Suspense>
div>
);
}
Both requests fire immediately. Each component resolves and renders independently.
Choosing Between Strategies
Use Promise.all when the data is needed together and a single loading state is acceptable. If either request fails, the combined promise rejects and both sections fall to the error boundary together. When you need independent failure handling — show the user card even if stats fail — use Strategy B with sibling Suspense boundaries, each wrapped in its own error boundary. Strategy B also wins when UI sections are independent and progressive loading improves perceived performance: the faster section appears first while the slower one keeps loading.
Pattern 3: Cached Fetching to Prevent Redundant Requests
The Problem: Re-renders Re-creating Promises
Without caching, when the parent re-renders it creates a new promise, which fires a new network request and restarts the Suspense cycle. Navigating away from a component and returning refetches everything. This is the most common real-world issue developers encounter with use().
Building a Simple Promise Cache
A Map-based cache stores promises by key and returns existing ones on cache hits. The cache includes a size cap and evicts failed promises so that transient errors don’t become permanent:
const CACHE_MAX_SIZE = 100;
const promiseCache = new Map();
function evictOldestIfNeeded() {
if (promiseCache.size >= CACHE_MAX_SIZE) {
const firstKey = promiseCache.keys().next().value;
promiseCache.delete(firstKey);
}
}
function cachePromise(key, fetcher) {
if (!promiseCache.has(key)) {
evictOldestIfNeeded();
const p = fetcher().catch(err => {
promiseCache.delete(key);
throw err;
});
promiseCache.set(key, p);
}
return promiseCache.get(key);
}
function App() {
const userPromise = cachePromise('user-1', () => fetchUser(1));
return (
<Suspense fallback={<div>Loading...div>}>
<UserProfile userPromise={userPromise} />
Suspense>
);
}
Subsequent renders return the same promise instance, avoiding duplicate requests. Invalidate the cache by deleting the relevant key when data is known to be stale: promiseCache.delete('user-1').
Warning: This module-scoped cache persists for the application’s lifetime. For SSR environments, use request-scoped caching (e.g., React’s
cache()for server components) — a module-scoped Map shares state across requests and leaks one user’s data to another. For client SPAs, consider adding a TTL or manual invalidation strategy to avoid stale data.
Using React’s Built-in cache() for Server Components
React 19 provides a cache() function for memoizing data fetches in React Server Components only (requires a framework like Next.js App Router). It is not available in standard client-side React applications. It deduplicates calls with the same arguments within a single server render pass.
import { cache } from 'react';
import { db } from './database';
const getUser = cache(async (id) => {
return await db.users.findUnique({ where: { id } });
});
async function UserHeader() {
const user = await getUser(1);
return <h1>{user.name}h1>;
}
This applies exclusively to React Server Components. Client-side caching requires the Map-based approach shown above or a library like TanStack Query.
Integrating with TanStack Query or SWR
For client-side caching requirements such as pagination, background refetching, optimistic updates, and stale-while-revalidate strategies, a dedicated library remains warranted. TanStack Query (v5) exposes promises in its experimental Suspense mode that work with use(), allowing the two approaches to coexist. The library manages the cache lifecycle; use() handles the Suspense integration. For applications that need this level of sophistication, the library documentation covers integration patterns directly.
Pattern 4: Dynamic Data Fetching Based on User Interaction
Fetching on Button Click or Route Change
User-triggered fetches require creating the promise in an event handler and storing it in state so the consuming component can access it:
import { use, useState, useRef, Suspense, startTransition } from 'react';
function SearchPanel() {
const [query, setQuery] = useState('');
const [resultsPromise, setResultsPromise] = useState(null);
const latestPromiseRef = useRef(null);
function handleSearch() {
const trimmed = query.trim();
if (!trimmed) return;
startTransition(() => {
const p = fetchResults(trimmed);
latestPromiseRef.current = p;
setResultsPromise(p);
});
}
return (
<div>
<label htmlFor="search-input">Searchlabel>
<input
id="search-input"
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSearch()}
/>
<button onClick={handleSearch}>Searchbutton>
<Suspense fallback={<div>Searching...div>}>
{resultsPromise && <SearchResults resultsPromise={resultsPromise} />}
Suspense>
div>
);
}
function SearchResults({ resultsPromise }) {
const results = use(resultsPromise);
return (
<ul>
{results.map((r, i) => <li key={r.id ?? i}>{r.title}li>)}
ul>
);
}
The event handler creates the promise and stores it via useState. The SearchResults component reads it with use(). The promise is created in the handler, not during render, so the critical rule is satisfied. Empty queries are ignored, and a latestPromiseRef tracks the most recent request to help manage rapid successive searches.
Note: For production use, consider debouncing handleSearch or using AbortController to cancel in-flight requests, since startTransition does not cancel previous requests automatically. A slow earlier response can still overwrite a faster later one without additional safeguards.
Why startTransition Matters Here
Without startTransition, setting the new promise in state causes React to immediately suspend the component, unmounting the current UI and showing the Suspense fallback. The user sees a loading spinner replace the search results they were just looking at.
With startTransition, React marks the state update as non-urgent. It keeps the current UI visible and interactive while the new data loads in the background. Only once the promise resolves does React swap in the updated UI. Without it, users see the Suspense fallback flash on every search; with it, the previous results stay visible until new ones arrive.
Complete Implementation: Putting It All Together
The following mini-application combines all four patterns across multiple files. This is the authoritative, complete version — if standalone pattern examples above differ in imports or details, defer to this implementation.
const CACHE_MAX_SIZE = 100;
const promiseCache = new Map();
function evictOldestIfNeeded() {
if (promiseCache.size >= CACHE_MAX_SIZE) {
const firstKey = promiseCache.keys().next().value;
promiseCache.delete(firstKey);
}
}
export function cachePromise(key, fetcher) {
if (!promiseCache.has(key)) {
evictOldestIfNeeded();
const p = fetcher().catch(err => {
promiseCache.delete(key);
throw err;
});
promiseCache.set(key, p);
}
return promiseCache.get(key);
}
function fetchWithTimeout(url, timeoutMs = 5000) {
return fetch(url, { signal: AbortSignal.timeout(timeoutMs) }).then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}: ${r.statusText || 'Unknown error'}`);
return r.json();
});
}
export function fetchUser(id) {
return fetchWithTimeout(`/api/users/${id}`);
}
export function fetchStats(id) {
return fetchWithTimeout(`/api/stats/${id}`);
}
export function fetchResults(query) {
return fetchWithTimeout(`/api/search?q=${encodeURIComponent(query)}`);
}
export function fetchPosts(userId) {
return fetchWithTimeout(`/api/posts?userId=${encodeURIComponent(userId)}`);
}
import { ErrorBoundary } from 'react-error-boundary';
function ErrorFallback({ error, resetErrorBoundary }) {
console.error('[AppErrorBoundary]', error);
return (
<div role="alert">
<p>Something went wrong. Please try again.p>
<button onClick={resetErrorBoundary}>Try againbutton>
div>
);
}
export function AppErrorBoundary({ children }) {
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
{children}
ErrorBoundary>
);
}
import { use, Suspense } from 'react';
import { cachePromise, fetchUser, fetchStats } from './api';
function UserCard({ userPromise }) {
const user = use(userPromise);
return <h2>{user.name} — {user.email}h2>;
}
function StatsPanel({ statsPromise }) {
const stats = use(statsPromise);
return <p>Posts: {stats.posts}, Followers: {stats.followers}p>;
}
export function Dashboard({ userId }) {
const userPromise = cachePromise(`user-${userId}`, () => fetchUser(userId));
const statsPromise = cachePromise(`stats-${userId}`, () => fetchStats(userId));
return (
<div>
{}
<Suspense fallback={<div>Loading user...div>}>
<UserCard userPromise={userPromise} />
Suspense>
<Suspense fallback={<div>Loading stats...div>}>
<StatsPanel statsPromise={statsPromise} />
Suspense>
div>
);
}
import { use, useState, useRef, Suspense, startTransition } from 'react';
import { fetchResults } from './api';
function SearchResults({ resultsPromise }) {
const results = use(resultsPromise);
return (
<ul>
{results.map((r, i) => (
<li key={r.id ?? i}>{r.title}li>
))}
ul>
);
}
export function SearchPanel() {
const [query, setQuery] = useState('');
const [resultsPromise, setResultsPromise] = useState(null);
const latestPromiseRef = useRef(null);
function handleSearch() {
const trimmed = query.trim();
if (!trimmed) return;
startTransition(() => {
const p = fetchResults(trimmed);
latestPromiseRef.current = p;
setResultsPromise(p);
});
}
return (
<div>
<label htmlFor="search-input">Searchlabel>
<input
id="search-input"
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSearch()}
/>
<button onClick={handleSearch}>Searchbutton>
<Suspense fallback={<div>Searching...div>}>
{resultsPromise && <SearchResults resultsPromise={resultsPromise} />}
Suspense>
div>
);
}
import { AppErrorBoundary } from './ErrorBoundary';
import { Dashboard } from './Dashboard';
import { SearchPanel } from './SearchPanel';
export default function App() {
return (
<AppErrorBoundary>
<Dashboard userId={1} />
<SearchPanel />
AppErrorBoundary>
);
}
Each file is annotated with the pattern it demonstrates. The AppErrorBoundary catches rejected promises from any child. Dashboard uses cached promises and sibling Suspense boundaries for parallel loading. SearchPanel creates promises in an event handler with startTransition.
Implementation Checklist
- ✅ Promises are created outside the component that calls
use() - ✅ Every
use()component is wrapped in aboundary - ✅ An
sits above or around each - ✅ Parallel fetches use
Promise.allor sibling Suspense boundaries - ✅ Promises are cached to prevent duplicate requests on re-render. If using SSR, scope caches per-request instead of per-module to prevent data leaking between users.
- ✅ User-triggered fetches use
startTransitionto preserve current UI - ✅ Client-side caching beyond simple deduplication delegates to TanStack Query or SWR
For server components, use React’s cache() for request deduplication within a single render pass.
Common Pitfalls and How to Avoid Them
Creating Promises During Render (Infinite Suspense)
The component creates a new promise each render, triggering suspension, which triggers another render. The fix: lift promise creation into a parent component, event handler, or cache. See the WRONG/RIGHT examples in “The Critical Rule: Never Create Promises Inside the Render” section above.
Component suspended without a Suspense parent
You’ll see this error when no boundary sits above the component calling use(). React cannot render a fallback because none exists. Wrap the consuming component as shown in the App function in Pattern 1.
Rejected Promises with No Error Boundary
A rejected promise with no error boundary crashes the entire React tree. In development, this surfaces as an error overlay; in production, users see a blank screen. Wrap every boundary with an , as demonstrated in “Adding an Error Boundary” in Pattern 1.
Using use() in Class Components
use() works only in function components and other hooks. Class components cannot call it. This is consistent with all React hooks but worth stating explicitly. React still lacks a hooks-based error boundary API. For new projects, use the react-error-boundary library (already installed from Pattern 1). Writing a class-based error boundary manually is the legacy approach and unnecessary if you use that library.
When to Use use() and When Not To
The use() hook targets Suspense-first architectures where data dependencies are co-located with the components that render them. It eliminates the majority of useEffect-based fetch patterns and the boilerplate that comes with them.
use() does not replace TanStack Query or SWR when your app needs cache TTLs, background refetches, pagination cursors, or optimistic rollback. Those libraries handle all of that; use() does not attempt to.
For teams adopting React 19, starting with Patterns 1 and 4 provides the fastest path to tangible improvements. Layer in caching (Pattern 3) and parallel fetching (Pattern 2) as application complexity grows and redundant requests or waterfalls become measurable problems.

