Reading promises with use()
The React 19 use() API for reading a server-streamed promise during render instead of fetching in an effect.
Picture a dashboard route. It’s a Server Component, and as it renders it starts a slow read against the database: getActivity(orgId), which loads the team’s recent activity. That data feeds an activity panel you can filter and mark items as read in, so the panel has to be a Client Component. The read happens on the server; the interactive component lives on the client; the data has to cross from one to the other.
You already know the three approaches that don’t work. A Client Component can’t await the read, because Client Components can’t be async. It shouldn’t fetch in an effect: effects don’t run during server rendering, so the HTML would ship empty. And it shouldn’t hand-roll useState, a spinner, and an error flag, the manual orchestration earlier lessons have been retiring.
The approach that clears all three: the Server parent starts the read and passes the unresolved promise down as a prop, and the Client child reads it with use(), letting React handle the waiting. One line streams a server read into a Client Component. We’ll also cover when to reach for TanStack Query instead.
The use() signature and its four outcomes
Section titled “The use() signature and its four outcomes”Start with the signature and the one idea to hold onto:
import { use } from 'react';
const value = use(resource);resource is a Promise<T> or a Context<T>, and use() returns the T. Hold onto one sentence: use() reads a resource during render and either returns its value or pauses the component until the value is ready. It’s a synchronous-looking read that’s allowed to interrupt.
Which one happens depends on what you pass:
- The promise is still pending → the component suspends : it stops rendering and waits.
- The promise has resolved →
use()returns the resolved value. - The promise rejected →
use()throws, and the nearest error boundary catches it. - You passed a Context →
use()returns its current value, and unlikeuseContextyou can read it conditionally, which a later section covers.
use() is imported from react alongside the hooks, but React calls it an API, not a hook. That’s why it’s allowed to break a rule every hook obeys: you can call it conditionally, inside an if or a loop. Why that’s safe comes at the end of the lesson; for now, just hold onto the fact that it can.
Streaming a server read into a Client Component
Section titled “Streaming a server read into a Client Component”The pattern takes two files: a Server Component that starts the read, and a Client Component that reads it.
import { Suspense } from 'react';import { getActivity } from '@/lib/activity';import { ActivityPanel } from './activity-panel';import { ActivitySkeleton } from './activity-skeleton';
export default async function DashboardPage({ orgId }: { orgId: string }) { const activityPromise = getActivity(orgId);
return ( <Suspense fallback={<ActivitySkeleton />}> <ActivityPanel activityPromise={activityPromise} /> </Suspense> );}The server starts the read but never awaits it. getActivity(orgId) returns a promise that crosses into the Client child as a plain prop. Awaiting it here would block the whole page on the slow query, which is exactly what you’re avoiding.
'use client';
import { use } from 'react';import type { Activity } from '@/lib/activity';
export const ActivityPanel = ({ activityPromise,}: { activityPromise: Promise<Activity[]>;}) => { const activity = use(activityPromise);
return ( <ul> {activity.map((item) => ( <li key={item.id}>{item.title}</li> ))} </ul> );};The child reads the promise and renders as if the data is already there. No effect, no useState, no if (!data) return <Spinner/>. use(activityPromise) either returns the array or suspends, and the child only commits once the data has arrived, so .map can assume activity is a real array every time.
What crosses the boundary is an unresolved promise, one of the values the RSC wire knows how to carry. The Server Component hands the Client Component a promise, and React streams the resolved value across when it settles. That value still has to be serializable, which is why getActivity returns plain objects, never class instances. The next chapter covers the full set of rules; for now, promises are allowed across.
The part that trips people up the first time is why this doesn’t block the page. The Server Component didn’t await, so it returns immediately, and the Suspense boundary renders its fallback while the child is suspended. Then the promise resolves and the child re-renders with real data.
Dashboard
Revenue
$48k
Members
312
Already painted — nav and stats respond to clicks.
showing
The server didn't wait, so the page is already up. Only the panel inside the boundary is paused.
Dashboard
Revenue
$48k
Members
312
Did not wait — usable while the panel loads.
showing fallback — <ActivitySkeleton />
The boundary shows its fallback while the child waits. The shell around it stays live.
Dashboard
Revenue
$48k
Members
312
Unchanged — the read finished off to the side.
The promise settled. Its value crosses the RSC wire — but the fallback is still showing until React re-renders.
Dashboard
Revenue
$48k
Members
312
Still the same shell — it was never re-mounted.
- Invited a teammate
- Created an invoice
On this render use(activityPromise) returns the array, so the panel renders in the fallback's place.
Dashboard
Revenue
$48k
Members
312
Visible the whole time — it never blocked.
<ActivityPanel /> — live
- Invited a teammate
- Created an invoice
- Archived a project
One boundary, one slow read — and the page was interactive from the first frame to the last.
Here’s the 2020 version of the same component beside the 2026 one:
'use client';
export const ActivityPanel = ({ orgId }: { orgId: string }) => { const [activity, setActivity] = useState<Activity[] | null>(null);
useEffect(() => { getActivity(orgId).then(setActivity); }, [orgId]);
if (!activity) return <ActivitySkeleton />;
return ( <ul> {activity.map((item) => ( <li key={item.id}>{item.title}</li> ))} </ul> );};Every cost is manual. It renders twice, empty then filled. You wire the loading branch by hand, and there’s no catch, so an error just vanishes. Worse, the effect can’t run during server rendering, so the server-rendered HTML ships with no data, even though the server could have had it ready.
'use client';
export const ActivityPanel = ({ activityPromise,}: { activityPromise: Promise<Activity[]>;}) => { const activity = use(activityPromise);
return ( <ul> {activity.map((item) => ( <li key={item.id}>{item.title}</li> ))} </ul> );};The orchestration is gone. One render with real data. Loading is the <Suspense> boundary’s job, errors are the error boundary’s job, and because the server started the read, the markup streams down already filled in. The component shrank to its real responsibility: rendering the list.
The useState, the useEffect, and the if (!activity) branch are all gone, leaving only the rendering logic. This is the async version of “delete the effect, derive in render” from earlier in the chapter: delete the effect, read the promise.
One boundary around the panel is plenty here. How granular to make boundaries, how to nest them, and how they drive streaming is a UX decision a later chapter owns; for this lesson, one boundary shows a fallback while the thing inside it loads.
The stable-promise rule
Section titled “The stable-promise rule”One mistake turns use() from a one-liner into a component that spins forever:
'use client';
export const ActivityPanel = () => { const activity = use(fetch('/api/activity').then((r) => r.json()));
return <ActivityList activity={activity} />;};Trace the loop. The component renders, and the render call creates a brand-new promise from fetch(...). use() sees it pending and suspends. The promise resolves, React re-renders, and the render runs fetch(...) again, creating another promise. Suspend, resolve, re-render, new promise: it never settles.
You can spot this bug from its two symptoms without reading the code: a component stuck on its fallback forever, and the Network tab firing the same request on a loop.
The rule that prevents it: the promise you pass to use() must be referentially stable across renders for the same logical resource. use() tracks the promise by Object.is reference. A new promise object every render reads as a new resource every render, so React keeps starting over.
So where does a stable promise come from? Two sanctioned sources:
- Created in a Server Component. A Server Component runs once per request, so
getActivity(orgId)produces one promise that streams down once. That stability is free, which is why the headline pattern leads with the server parent. - Held in a stable reference, for promises that genuinely originate on the client. The right tool there is TanStack Query, covered in the next section.
You might reach for useMemo(() => fetch(...), []) to pin the promise, but that is the wrong move. Manual memoization is a fragile last resort, and it does nothing for the caching and refetching client data actually needs. For client data you want to read and cache, the answer is TanStack Query, not a memo around a fetch.
One related name is worth recognizing: React’s server-only cache(). It deduplicates calls to the same function with the same arguments within a request, so two components calling getActivity(orgId) share one database round-trip instead of two. use() itself does not deduplicate. Two children calling use(samePromise) are fine, because that is one reference and one resource, but two children that each create their own promise for the same data suspend independently. cache() collapses duplicate function calls; use() only reads the promise you hand it.
Now put the diagnosis to the test. One of these three components suspends forever. Which one?
One of these activity panels never escapes its loading fallback. Which one — and why?
const Panel = ({ orgId }: { orgId: string }) => { const activity = use(loadActivity(orgId)); return <List activity={activity} />;};const Panel = ({ activityPromise }: { activityPromise: Promise<Activity[]> }) => { const activity = use(activityPromise); return <List activity={activity} />;};const Page = async ({ orgId }: { orgId: string }) => { const activityPromise = loadActivity(orgId); return <Panel activityPromise={activityPromise} />;};loadActivity(orgId) inside the render body, so every render builds a brand-new promise. use() tracks promises by reference, sees a different one each time, and suspends forever — fallback stuck, the same request looping in the Network tab. The other two pass a promise created outside the re-rendering component (as a prop, or once in a Server parent), so the reference stays stable across renders and use() resolves once.use() vs. TanStack Query
Section titled “use() vs. TanStack Query”use() is not the only way to read async data on the client; knowing which to reach for is the skill.
For an initial, server-rendered read, fetched once and shown, use use() plus Server Components. Switch to TanStack Query the moment you need interactive client data: polling for fresh values, caching across views, optimistic updates with rollback, or infinite scroll. Cross any one of those four triggers and you’ve outgrown use().
That boundary follows from what each tool is. use() is the lower-level primitive: it reads a promise and suspends, with no cache, refetch, or mutations. TanStack Query is the batteries-included layer for client-owned server state, with all three built in.
| Situation | Reach for |
|---|---|
| Initial server-rendered read; fetch once and show it | use() + Server Components |
| Client data that polls, caches across views, does optimistic updates, or scrolls infinitely | TanStack Query |
The two compose rather than compete. TanStack Query’s suspense-flavored query hook suspends through the same <Suspense> boundary and fallback that use() taps into, so starting with use() for the server read doesn’t lock you out of the query library when a trigger later applies.
Reading context conditionally with use()
Section titled “Reading context conditionally with use()”use() does one more, smaller thing, worth keeping separate from the promise story.
Recall the rule from the useContext lesson: hooks must run at the top level, before any early return, so useContext can’t sit after an if, in a branch, or in a loop. use(Context) can. It returns the exact value useContext would; it only relaxes where you may call it.
This matters in one situation: a component returns early for a disabled or empty state and needs the context value only on the live path.
'use client';
export const ActivityPanel = ({ isEnabled }: { isEnabled: boolean }) => { if (!isEnabled) return null;
const theme = use(ThemeContext);
return <ul className={theme.listClass}>{/* … */}</ul>;};Reading the context at the top would be wasted work on the disabled path, or force you to restructure the component just to satisfy the top-level rule. use(ThemeContext) lets the call sit where the value is needed.
Be precise about what this does not change. use(Context) is not a different subscription model: it gives you no per-field subscriptions, and every consumer still re-renders when the context value changes. It moves where the call may sit, not what it subscribes to. Reach for it as the one thing useContext can’t do, not as a fix for context’s re-render cost.
Why use() can be called conditionally
Section titled “Why use() can be called conditionally”This pays off the detail flagged earlier. Every other hook must be called at the top level, in the same order every render; use() alone may be called conditionally, after early returns and inside branches or loops. That isn’t an oversight. It follows from how use() finds what it’s reading.
Regular hooks like useState and useEffect are tracked by call order. React keeps a list of slots and advances an index on each call, so the next render must repeat the exact same sequence for each useState to land on the slot holding its value. Skip one behind an if, and every later hook reads the wrong slot. That’s why the order is fixed, the subject of the next lesson.
use() works differently. A promise is tracked by its reference, a context by its position in the tree. Neither needs indexed-slot bookkeeping, so there’s no slot to desync and a conditional call can’t break anything. The linter knows this: react-hooks/rules-of-hooks exempts use() and won’t flag a conditional call.
Practice: replace the effect-and-spinner with use()
Section titled “Practice: replace the effect-and-spinner with use()”Here is that component in its 2020 form: it fetches on mount with useEffect, holds the data in useState, and shows a spinner until the data lands. Convert it to the use() shape.
It receives a stable promise prop, created once at module scope so re-renders never rebuild it, which keeps you clear of the infinite-loop trap and stands in for the Server parent you’d have in production. Read that promise with use(), then delete the useEffect, the useState, and the loading branch. A <Suspense> boundary already wraps the component, so the pending phase is covered.
Rewrite ActivityPanel to read activityPromise with use(), then delete the effect, the state, and the loading branch. The promise is created outside the component so it stays stable across renders — that's the stable-promise rule in practice. The panel is already wrapped in a Suspense boundary.
Reference solution
The promise scaffolding and App are unchanged; only ActivityPanel does, collapsing to the single highlighted use() read: no effect, no state, no loading branch.
import { Suspense, use } from 'react';
type Activity = { id: string; title: string };
export const activityPromise: Promise<Activity[]> = new Promise((resolve) => setTimeout( () => resolve([ { id: '1', title: 'Invited a teammate' }, { id: '2', title: 'Created an invoice' }, { id: '3', title: 'Archived a project' }, ]), 50, ),);
export const ActivityPanel = ({ activityPromise,}: { activityPromise: Promise<Activity[]>;}) => { const activity = use(activityPromise);
return ( <ul> {activity.map((item) => ( <li key={item.id}>{item.title}</li> ))} </ul> );};
export function App() { return ( <Suspense fallback={<p>Loading…</p>}> <ActivityPanel activityPromise={activityPromise} /> </Suspense> );}Keep going
Section titled “Keep going”The official API reference, including the context-reading form.
Request-scoped deduplication for server reads.
The named graduation path when client data needs caching, polling, or mutations.