Skip to content
Chapter 25Lesson 7

Reading promises with use()

The React 19 use() API for reading a server-streamed promise during render, the modern replacement for fetching data in an effect.

Picture a dashboard route. It’s a Server Component, and as it renders it starts a slow read: getActivity(orgId), a few hundred milliseconds against the database to load the team’s recent activity. That data feeds an activity panel, and the panel has to be interactive because you can filter it and mark items as read. An interactive component has to be a Client Component. So the read happens on the server, the component lives on the client, and you need to get the data from one to the other.

You already know the three approaches that don’t work. The Client Component can’t await the read itself, because Client Components can’t be async. It shouldn’t useEffect(() => getActivity().then(setData), []) either: that’s the fetch-on-mount anti-pattern this chapter has been dismantling, and effects don’t run during server rendering, so the server-rendered HTML would ship with no data in it. And it shouldn’t hand-roll useState plus a spinner plus an error flag, the manual orchestration the earlier lessons have spent their time retiring.

Here’s the approach that clears all three at once. The Server parent starts the read and passes the unresolved promise down as a prop. The Client child reads that promise with use() and lets React handle the waiting. By the end of this lesson you’ll be able to stream a server read into a Client Component with a single line, and you’ll know when to reach for TanStack Query instead.

Before any of the machinery, 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() hands you back the T. That’s the whole surface. The mental model fits in one sentence: use() reads a resource during render and either hands back its value or pauses the component until the value is ready. It’s a synchronous-looking read that’s allowed to interrupt. The rest of this lesson is about where that pause goes and what keeps it safe.

There are four outcomes, depending on what you hand it:

  • 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. Unlike useContext, you can read it conditionally, which a later section covers.

One detail is worth noting now: use() is imported from react right alongside the hooks, but React deliberately calls it an API, not a hook. That distinction has a practical consequence. It’s the reason use() is allowed to break a rule every other hook obeys: you can call it conditionally, inside an if or a loop. We’ll come back to why that’s safe at the end of the lesson, so 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”

This is the use case the lesson is built around, so let’s see the whole pattern. It 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, and that unresolved promise 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 trying to avoid. (orgId is a plain prop here just to keep the example focused. A real App Router page receives params and searchParams instead, which a later chapter covers.)

Look at what crosses the boundary: an unresolved promise. A promise is one of the things the RSC wire knows how to carry. A Server Component can hand a Client Component a promise, and React streams the resolved value across when it settles. The resolved value still has to be serializable, which is why getActivity returns plain objects, never class instances. The full set of rules for what crosses that wire is the next chapter’s job; for now, “promises are allowed across” is all you need.

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. The Suspense boundary renders its fallback while the child is suspended. Then the promise resolves and the child re-renders with real data. You can scrub through the whole sequence below:

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.

The contrast is clearest when you put this next to the code it replaces. 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. The bigger problem is that 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 deletions are the point. The useState, the useEffect, and the if (!activity) loading branch are all gone, and what’s left is the rendering logic and nothing else. This is the async version of “delete the effect, derive in render” from earlier in this chapter: delete the effect, read the promise.

One question you might already be forming: where does the <Suspense> boundary go, and how many should there be? One around the panel is plenty here. How granular to make boundaries, how to nest them, and how they drive streaming is a UX decision, and a later chapter on Suspense and streaming owns it. For this lesson, “one boundary shows a fallback while the thing inside it loads” is the entire contract you need.

There’s one mistake that turns use() from a one-liner into a component that spins forever, and it’s a common one to hit early. Here it is:

'use client';
export const ActivityPanel = () => {
const activity = use(fetch('/api/activity').then((r) => r.json()));
return <ActivityList activity={activity} />;
};

Follow the loop one step at a time. The component renders, and the render call creates a brand-new promise from fetch(...). use() sees a pending promise and suspends. The promise resolves. React re-renders the component, and the render runs fetch(...) again, creating another brand-new promise. use() sees a pending promise and suspends again. Resolve, re-render, new promise, suspend: it never settles.

You can recognize this bug by its two symptoms before you ever read the code: a component stuck on its fallback forever, and the Network tab firing the same request on a loop. Once you’ve seen that pair together, you’ll diagnose it quickly.

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 reference, the same Object.is identity rule from the previous chapter on hooks. A new promise object on every render reads as a new resource on 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, which streams down once. That’s exactly the headline pattern, and the stability is free: you didn’t have to do anything for it. This is why the pattern leads with the server parent.
  2. Held in a stable reference, for promises that genuinely originate on the client. The right tool for client-owned, cacheable async is TanStack Query, covered in the next section. On the server, cache() (below) gives you stable, deduplicated reads.

You might be tempted to reach for useMemo(() => fetch(...), []) to pin the promise, but that’s the wrong move. Manual memoization is a fragile last resort the conventions steer away from, and for client-side data that needs caching or refetching it doesn’t solve the real problem anyway. The honest answer for “client data I want to read and cache” 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 single request, so if two components both call getActivity(orgId), they share one database round-trip instead of two. There’s a subtle distinction here. use() itself does not deduplicate. Two children calling use(samePromise) on the same promise reference is fine, because that’s one reference and one resource. But two children that each create their own promise for the same data will suspend independently. cache() collapses the duplicate function calls, while use() only reads whatever promise you hand it. The full data-caching path is a later chapter. For now, just recognize the name.

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, and the useful skill is knowing which one to reach for. The aim is to make that choice automatic.

For an initial, server-rendered read, meaning “fetch it once on the server and show it,” use use() plus Server Components. The moment you need interactive client data, such as polling for fresh values, caching across views, optimistic updates with rollback, or infinite scroll, reach for TanStack Query. Those are the four triggers: cross any one of them and you’ve outgrown use().

The reason is that the two sit at different levels. use() is the lower-level primitive: no cache, no automatic refetch, no mutation story. It reads a promise and suspends, and that’s it. TanStack Query is the batteries-included layer for client-owned server state. It is a cache, with refetching, polling, and mutations built in. Here’s the decision in one glance:

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> machinery use() taps into, with the same fallback and the same boundary. So picking use() now doesn’t lock you out of the query library later: it’s the same “platform default before the power tool” shape you’ve seen throughout this chapter. Start with use() for the server read, and reach for the heavier tool the moment one of the named triggers applies. The full TanStack Query API is a later chapter; here, the point is just the decision boundary, not the tool itself.

The promise story is the headline. There’s a second, much smaller thing use() can do, and it’s worth keeping separate in your mind so you don’t conflate the two.

Recall the rule from the lesson on useContext: hooks must run at the top level, before any early return. That means useContext can’t sit after an if, inside a branch, or inside a loop. use(Context) can. It returns the exact same value useContext would; it just relaxes where you’re allowed to call it.

Here’s the one situation that actually calls for it. A component returns early for a disabled or empty state, and only needs the context value 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 unconditionally at the top would be wasted work on the disabled path, or it would force you to restructure the component awkwardly just to satisfy the top-level rule. use(ThemeContext) lets the call sit exactly where the value is actually needed.

Be precise about what this does not change, because the wrong inference is easy to make. use(Context) is not a different subscription model. It does not give you per-field subscriptions, and it does not change the rule from that earlier lesson that every consumer re-renders when the context value changes. The re-render behavior is identical; use() only moves where the call is allowed to sit, not what it subscribes to. So treat this as the one thing useContext can’t do, reached for rarely, not as a fix for context’s re-render cost.

Now we can return to the detail flagged at the start of the lesson. Every other hook you’ve met must be called at the top level, in the same order every render. use() alone may be called conditionally: after early returns, inside branches, inside loops. That’s not an inconsistency React forgot to fix. It follows from how use() finds the thing it’s reading.

Regular hooks like useState and useEffect are tracked by call order. React keeps an internal list of slots and advances an index every time you call one, and the next render has to hit the exact same sequence of calls so each useState lands on the slot that holds its value. Skip one behind an if, and every hook after it reads the wrong slot. That’s why the order has to be fixed, which is the next lesson’s whole subject.

use() works differently. A promise is tracked by its reference, and a context by its position in the component tree. Neither needs the indexed-slot bookkeeping that call-order tracking depends on. There’s no slot to desync, so calling use() conditionally can’t break anything. The linter knows this too: react-hooks/rules-of-hooks ships a built-in exemption for 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()”

Now try the deletion yourself. Below is the component you’ve been reading about in its 2020 form: it fetches on mount with useEffect, holds useState for the data, and renders a spinner until the data lands. Your job is to convert it to the use() shape.

It receives a stable promise prop, created once at module scope, outside the re-rendering component, so you don’t trip the infinite-loop trap mid-exercise. That’s the stable-promise rule made literal, standing in for the Server parent you’d have in production. Rewrite the body to read that promise with use(), then delete the useEffect, the useState, and the loading branch. The component is already wrapped in a <Suspense> boundary, so the pending phase is covered for you.

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 from the starter; only ActivityPanel changes. Its body collapses to the single use() read (highlighted): 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>
    );
    }