React 19 use() Hook: Promise Mechanics and Suspense Integration
The use() hook throws promises to trigger Suspense — but promise identity, conditional calls, and SSR streaming create unexpected behaviors.
React 19's use() hook reads values from promises and contexts.
Unlike other hooks, it can be called inside conditionals and loops.
But understanding how it works — throwing promises to trigger Suspense —
reveals edge cases around promise identity, re-render behavior, and SSR streaming
that can bite you in production.
How use() Actually Works: Throwing Promises
When you call use(promise) with a pending promise, React does something unusual:
it throws the promise. React catches this thrown promise and hands it
to the nearest <Suspense> boundary, which shows the fallback.
When the promise resolves, React re-renders the component with the resolved value.
// Simplified mental model of use()
function use(promise) {
if (promise.status === 'pending') {
throw promise; // Triggers Suspense!
}
if (promise.status === 'rejected') {
throw promise.reason; // Triggers Error Boundary
}
return promise.value; // Already resolved
}
This throw-based mechanism is why use() cannot be wrapped in try-catch —
React needs to catch the thrown promise, not your error handler.
The Promise Identity Problem
Here's where things get tricky. Consider this code:
function UserProfile({ userId }) {
// ❌ New promise created every render!
const user = use(fetchUser(userId));
return <div>{user.name}</div>;
}
If fetchUser(userId) returns a new promise on every call (which most fetch
functions do), each re-render creates a new pending promise. React sees a new
pending promise, throws it again, shows the fallback again — infinite loading loop.
Solution: Stabilize Promise Identity
// ✅ Create promise outside component, or use a cache
function UserProfile({ userPromise }) {
const user = use(userPromise);
return <div>{user.name}</div>;
}
// In parent:
const userPromise = fetchUser(userId); // Created once
<Suspense fallback={<Spinner />}>
<UserProfile userPromise={userPromise} />
</Suspense>Or with a caching layer:
// Simple cache for promise identity
const cache = new Map();
function getCachedUser(userId) {
if (!cache.has(userId)) {
cache.set(userId, fetchUser(userId));
}
return cache.get(userId);
}
function UserProfile({ userId }) {
// ✅ Same promise returned for same userId
const user = use(getCachedUser(userId));
return <div>{user.name}</div>;
}Conditional use(): Why It's Different
Unlike useState or useEffect, use() can be
called conditionally:
function OptionalData({ shouldLoad, dataPromise }) {
// ✅ Allowed! use() can be conditional
if (shouldLoad) {
const data = use(dataPromise);
return <DataView data={data} />;
}
return <Placeholder />;
}
This works because use() doesn't rely on hook call order the same way
other hooks do. It reads from the promise directly rather than storing state per position.
The Conditional Reveal Gotcha
But there's a subtle issue when conditionally revealing already-resolved promises:
function App() {
const [show, setShow] = useState(false);
// Promise resolves in 2 seconds
const dataPromise = useMemo(
() => new Promise(r => setTimeout(() => r('data'), 2000)),
[]
);
return (
<>
<button onClick={() => setShow(true)}>Reveal</button>
{show && (
<Suspense fallback="Loading...">
<DataDisplay promise={dataPromise} />
</Suspense>
)}
</>
);
}If you click "Reveal" after the 2-second timeout, you might expect immediate display. But React may still show the fallback briefly because the component wasn't mounted when the promise resolved — React needs to "catch up" and verify the promise state.
use() with Context: The Flexible Alternative
use() also reads context, offering flexibility useContext() lacks:
function ConditionalTheme({ useCustomTheme }) {
// ✅ Context can be read conditionally!
if (useCustomTheme) {
const theme = use(ThemeContext);
return <div style={{ background: theme.bg }}>...</div>;
}
return <div>Default theme</div>;
}
With useContext(), you'd have to call it unconditionally and then
conditionally use the value. use() allows the read itself to be conditional.
Error Handling: No try-catch Allowed
Because use() throws promises (and errors), you cannot wrap it in try-catch:
function BadErrorHandling({ promise }) {
try {
// ❌ This breaks Suspense!
const data = use(promise);
return <DataView data={data} />;
} catch (e) {
// This catches the thrown promise, breaking React
return <Error error={e} />;
}
}
Instead, handle errors with Error Boundaries or Promise.catch():
// Option 1: Error Boundary
<ErrorBoundary fallback={<ErrorUI />}>
<Suspense fallback={<Loading />}>
<DataComponent promise={dataPromise} />
</Suspense>
</ErrorBoundary>
// Option 2: Transform the promise
const safePromise = dataPromise.catch(err => ({ error: err }));
// Then check for error property in componentSSR Streaming: Server vs Client Behavior
In Server Components, prefer async/await over use():
// ✅ Server Component: use async/await
async function ServerProfile({ userId }) {
const user = await fetchUser(userId);
return <div>{user.name}</div>;
}
// Client Component receiving server promise
'use client';
function ClientProfile({ userPromise }) {
const user = use(userPromise); // ✅ use() for passed promises
return <div>{user.name}</div>;
}
Key difference: await in Server Components blocks rendering
at that point. use() in Client Components allows streaming — the
server sends the Suspense fallback immediately, then streams the resolved content.
Serialization Constraint
When passing promises from Server to Client Components, the resolved value must be serializable — no functions, symbols, or circular references:
// ❌ Functions can't be serialized
const badPromise = Promise.resolve({
name: 'User',
onClick: () => console.log('click') // Error!
});
// ✅ Only serializable data
const goodPromise = Promise.resolve({
name: 'User',
id: 123
});Nested Suspense and Waterfall Prevention
Multiple use() calls create sequential loading by default:
function Dashboard() {
const user = use(userPromise); // Suspends
const posts = use(postsPromise); // Waits for user first!
const stats = use(statsPromise); // Waits for posts!
return <DashboardView {...{user, posts, stats}} />;
}
Each use() throws and suspends before the next one runs. For parallel loading,
start all promises before any use():
function Dashboard({ userId }) {
// Start all fetches immediately (in parent or via cache)
const userPromise = fetchUser(userId);
const postsPromise = fetchPosts(userId);
const statsPromise = fetchStats(userId);
return (
<Suspense fallback={<DashboardSkeleton />}>
<DashboardContent
userPromise={userPromise}
postsPromise={postsPromise}
statsPromise={statsPromise}
/>
</Suspense>
);
}When to Use use() vs Other Patterns
- use(): For promises passed as props (especially from Server Components) or conditional context reads
- React Query/SWR: For client-side fetching with caching, refetching, and stale-while-revalidate
- Server Components + await: For server-side data that doesn't need client interactivity
- useEffect: For side effects that shouldn't suspend the UI
use() is a low-level primitive. Most applications will use it indirectly
through frameworks like Next.js or libraries that handle promise caching internally.
Understanding the throwing mechanism helps debug when Suspense doesn't behave as expected.
Advertisement
Explore these curated resources to deepen your understanding
Official Documentation
Tools & Utilities
Related Insights
Explore related edge cases and patterns
Advertisement