The `use` Hook Meets Suspense: What Actually Happens When You Pass a Promise to a Client Component
React 19's `use` hook lets you unwrap promises inside client components. It's elegant until a parent re-renders and your tab goes blank. Here's what's actually happening and how to not get burned.
The use hook shipped in React 19 and immediately became the most misunderstood API in the App Router toolkit. On paper it's a clean way to unwrap a promise inside a client component without useEffect gymnastics. In practice, we've debugged three production incidents in the last quarter that all traced back to the same mistake: creating the promise in the wrong place.
This is a breakdown of what use actually does with a promise, where the sharp edges live, and the patterns we now use by default on client projects.
What use is for, and what it isn't
The use hook does two things depending on what you hand it. Give it a Context and it reads it (like useContext, but callable conditionally). Give it a Promise and it suspends the component until the promise resolves, then returns the value.
That second behavior is the interesting one. It means a client component can finally participate in Suspense without wrapping everything in a data-fetching library.
'use client';
import { use } from 'react';
type Props = { userPromise: Promise<User> };
export function UserBadge({ userPromise }: Props) {
const user = use(userPromise);
return <span>{user.name}</span>;
}
The common pattern is: a Server Component kicks off the fetch (without awaiting), passes the unresolved promise as a prop, and the client component suspends on it. The HTML streams, the client hydrates, and when the data lands the fallback swaps out.
// app/profile/page.tsx — Server Component
import { Suspense } from 'react';
import { UserBadge } from './UserBadge';
async function getUser(): Promise<User> {
const res = await fetch('https://api.example.com/me', { cache: 'no-store' });
return res.json();
}
export default function Page() {
const userPromise = getUser(); // note: no await
return (
<Suspense fallback={<span>Loading…</span>}>
<UserBadge userPromise={userPromise} />
</Suspense>
);
}
This is the canonical example from the React team, and it works. The problem is what happens next, when your app grows past one page.
The identity trap: why your tab goes blank
Here is the rule nobody tells you clearly: use throws the promise if it isn't resolved, and React tracks resolution by the promise's object identity. If the parent re-renders and creates a new promise, React sees an unresolved promise it has never seen before and suspends again — even if a semantically identical fetch already completed a moment ago.
In a Server Component this is fine, because Server Components don't re-render on the client. But the moment you move that promise creation into a Client Component — or into something that gets re-invoked — you get this:
'use client';
export function Dashboard() {
const [tab, setTab] = useState('overview');
// BUG: new promise on every render
const dataPromise = fetchDashboardData(tab);
return (
<Suspense fallback={<Skeleton />}>
<DashboardPanel dataPromise={dataPromise} />
</Suspense>
);
}
Every time tab changes, dataPromise is a new object. use suspends. The Suspense boundary falls back to <Skeleton />. The user sees a loading flash on every tab click, even for cached data.
Worse: if the Suspense boundary is above a chunk of state, that state resets. We had a client ship this pattern around an infinite-scroll feed. Every filter change blew away scroll position because the boundary unmounted the children.
The fix is a stable reference
For client-created promises, memoize them against their inputs:
'use client';
import { useMemo, useState, Suspense } from 'react';
export function Dashboard() {
const [tab, setTab] = useState('overview');
const dataPromise = useMemo(
() => fetchDashboardData(tab),
[tab],
);
return (
<Suspense fallback={<Skeleton />}>
<DashboardPanel dataPromise={dataPromise} />
</Suspense>
);
}
This is better, but it still re-fetches whenever tab changes. That's often what you want. If it isn't — if you want cross-render caching — use is the wrong tool. Reach for a library (TanStack Query, SWR, or the framework-level cache). use is a primitive, not a cache.
Server-to-client promise passing: the serialization question
When a Server Component passes a promise to a Client Component in the App Router, Next.js serializes it over the RSC payload. The client receives a promise that resolves when the server sends the resolved value as part of the streaming response.
Two things people get wrong here.
First, the promise's resolved value must be serializable. Dates, plain objects, arrays, primitives — fine. A Map, a class instance with methods, a function — not fine. We've seen teams return Mongoose documents directly and wonder why hydration throws. Call .toObject() or map to a DTO.
Second, you don't gain anything by passing a promise if the client component is going to await it immediately on first render with no other content to show. You've just moved the waterfall. The point of streaming with use is that the parent Server Component can render other HTML while this one promise is in flight. If there's nothing else to render, just await on the server and pass the resolved value.
When use actually earns its keep
The pattern we reach for use on is a page with several independent data sources, where some are slow and some are fast, and we want above-the-fold content to paint immediately.
// app/order/[id]/page.tsx
import { Suspense } from 'react';
import { OrderHeader } from './OrderHeader';
import { ShippingEstimate } from './ShippingEstimate';
import { Recommendations } from './Recommendations';
export default async function OrderPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const order = await getOrder(id); // fast, needed for header
const shippingPromise = getShippingEstimate(id); // slow, external API
const recsPromise = getRecommendations(order.userId); // slow, ML service
return (
<>
<OrderHeader order={order} />
<Suspense fallback={<ShippingSkeleton />}>
<ShippingEstimate estimatePromise={shippingPromise} />
</Suspense>
<Suspense fallback={<RecsSkeleton />}>
<Recommendations recsPromise={recsPromise} />
</Suspense>
</>
);
}
The header paints with the first byte. The two slow regions stream in independently. Neither blocks the other because the promises started in parallel.
This is the one pattern where use beats everything else. You get streaming SSR without pulling a client-side data library into the bundle, and the mental model matches how the HTTP response actually flows.
Error boundaries are not optional here
If the promise rejects, use re-throws. Without an Error Boundary wrapping the Suspense boundary, that rejection unwinds to the nearest error.tsx and the whole route segment goes to the error state. Usually not what you want for one slow panel.
<ErrorBoundary fallback={<ShippingError />}>
<Suspense fallback={<ShippingSkeleton />}>
<ShippingEstimate estimatePromise={shippingPromise} />
</Suspense>
</ErrorBoundary>
In Next.js, you can colocate error.tsx at a route segment, but for granular panel-level errors you need actual React Error Boundaries around each Suspense boundary. The React team has flagged a built-in Error Boundary component on the roadmap; until it lands, use react-error-boundary or roll your own class component.
The hidden cost: hydration and the client bundle
One thing nobody mentions in the use tutorials: the component that calls use is a Client Component. That means it, and everything it imports, ships to the browser. If UserBadge imports a 40 KB formatting library to render one field, you've paid a bundle tax to use streaming.
In our experience the right split is usually:
- Server Component does the fetch and renders the shell.
- A tiny Client Component wraps only the dynamic part and calls
use. - Heavy formatting, markdown rendering, charts — do them in the Server Component with the resolved value when possible.
If you find yourself with a 15 KB Client Component just to unwrap a promise, flip the architecture. Pass the resolved value from parent server to child server, and only drop to a client island for genuine interactivity.
Quick checklist before you ship
- Is the promise created in a Server Component, or memoized in a Client Component? If neither, you have the identity bug.
- Is the resolved value serializable across the RSC boundary?
- Is there a Suspense boundary close enough to the component that fallback doesn't unmount state you care about?
- Is there an Error Boundary around the Suspense boundary?
- Did you parallelize the promise creation, or did you accidentally await one before starting the next?
- Is the Client Component small enough that the bundle cost is worth the streaming win?
Where we'd start
If you're adopting use on an existing App Router project, don't rewrite data fetching. Pick one route with a visible waterfall — usually a detail page with a slow third-party call — and convert just that one slow panel to the Server-creates-promise, Client-calls-use pattern. Measure LCP and INP before and after. If you don't see a meaningful shift in Core Web Vitals, the waterfall wasn't the problem, and use won't save you.
For new projects, our default is: await on the server unless you can name the specific panel that's slow enough to deserve its own stream. use is a scalpel, not a hammer. If you want to talk through a specific case, our team covers patterns like this in web development engagements.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading
Partial Prerendering in Production: What Breaks When You Ship PPR to a Real App
PPR promises the static shell of a marketing page with the dynamic guts of an app. Here's what actually happens when you flip the flag on a codebase that wasn't designed for it.

The `useOptimistic` Rollback Problem: When Server Actions Fail Silently
React 19's useOptimistic is magic until a server action throws and your UI shows the wrong state. Here's how we debugged a silent rollback bug in production and the pattern we now use.

Server Actions Under Load: Why Our Form Submits Queued Behind a Slow Dashboard
A production war story about Next.js server actions, implicit route serialization, and the invisible queue that turned a 200ms form submit into a 4-second wait during peak hours.
