Skip to content
Chapter 25Lesson 7

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.

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 unlike useContext you 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.

app/dashboard/page.tsx
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.

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.

page shell interactive

Dashboard

Revenue

$48k

Members

312

Already painted — nav and stats respond to clicks.

<Suspense> boundary child suspended
getActivity() Promise (pending)

showing

paused <ActivityPanel />

The server didn't wait, so the page is already up. Only the panel inside the boundary is paused.

The Server Component starts the read and returns immediately — it does not await. The promise is pending and the child (ActivityPanel) suspends, so it stops rendering.
page shell interactive

Dashboard

Revenue

$48k

Members

312

Did not wait — usable while the panel loads.

<Suspense> boundary fallback
getActivity() Promise (pending)

showing fallback — <ActivitySkeleton />

The boundary shows its fallback while the child waits. The shell around it stays live.

React renders the fallback in the boundary's place. The skeleton fills the slot, and the rest of the page is already interactive — it never blocked on the slow read.
page shell interactive

Dashboard

Revenue

$48k

Members

312

Unchanged — the read finished off to the side.

<Suspense> boundary fallback
getActivity() Promise (resolved)
RSC wire value streaming to the child

The promise settled. Its value crosses the RSC wire — but the fallback is still showing until React re-renders.

A few hundred milliseconds later the promise resolves. React streams the resolved value to the client over the RSC wire. The fallback is still on screen — the re-render hasn't happened yet.
page shell interactive

Dashboard

Revenue

$48k

Members

312

Still the same shell — it was never re-mounted.

<Suspense> boundary re-rendering
getActivity() Promise (resolved)
<ActivityPanel /> use() → [...]
  • Invited a teammate
  • Created an invoice

On this render use(activityPromise) returns the array, so the panel renders in the fallback's place.

React re-renders the suspended child. use(activityPromise) now returns the array instead of suspending, so the boundary swaps the fallback for the real ActivityPanel.
page shell interactive

Dashboard

Revenue

$48k

Members

312

Visible the whole time — it never blocked.

<Suspense> boundary resolved
getActivity() Promise (resolved)

<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.

The fallback is gone, the panel shows the data, and the page never blocked. The shell was visible the entire time — the slow read only ever held up the one box that needed it.

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.

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.

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:

  1. 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.
  2. 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} />;
};

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.

SituationReach for
Initial server-rendered read; fetch once and show ituse() + Server Components
Client data that polls, caches across views, does optimistic updates, or scrolls infinitelyTanStack Query
The platform default for initial reads, and the upgrade when a trigger crosses.

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.

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.

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.

Preview
    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>
    );
    }