Skip to content

use() & Data Fetching

use() is a React 19 API that reads the value of a resource during render — most importantly a promise or a context. When you pass it a promise, the component suspends while the promise is pending (showing the nearest Suspense fallback) and returns the resolved value once it settles.

import { use, Suspense } from "react";
function Message({ messagePromise }) {
const text = use(messagePromise); // suspends until the promise resolves
return <p>{text}</p>;
}
function Container({ messagePromise }) {
return (
<Suspense fallback={<p>Loading…</p>}>
<Message messagePromise={messagePromise} />
</Suspense>
);
}

use() breaks one rule you learned for other hooks: it can be called conditionally — inside an if, or in a loop. That’s allowed precisely because it isn’t tied to the fixed hook slots the way useState/useEffect are.

src/__tests__/useContextConditional.test.tsx
import { createContext, use } from 'react';
import { render, screen } from '@testing-library/react';
const ThemeContext = createContext('light');
function ConditionalReader({ enabled }: { enabled: boolean }) {
let theme = 'n/a';
if (enabled) {
// A plain `if` around use(Context) — would be a rules-of-hooks violation
// for useContext, but use() is explicitly exempt.
theme = use(ThemeContext);
}
return <p data-testid="theme">{theme}</p>;
}
test('use(Context) can be called conditionally inside an if', () => {
const { rerender } = render(
<ThemeContext value="dark">
<ConditionalReader enabled={true} />
</ThemeContext>,
);
expect(screen.getByTestId('theme').textContent).toBe('dark');
rerender(
<ThemeContext value="dark">
<ConditionalReader enabled={false} />
</ThemeContext>,
);
expect(screen.getByTestId('theme').textContent).toBe('n/a');
});
✓ use(Context) can be called conditionally inside an if

use() in Server Components vs Client Components

Section titled “use() in Server Components vs Client Components”

Reading a promise with use() works in both Server and Client Components — it’s the mechanism a Server Component uses to hand an in-flight promise down to a Client Component without blocking its own render on it (see React 19: Server Components for the server/client boundary itself). Reading context with use() is different:

“Reading context with use is not supported in Server Components.” — react.dev, use

That makes sense once you remember Server Components never re-render in the browser — there’s no live subscription to a Provider to read from. If you’re working in a Next.js app specifically, the request/render pipeline that decides which of your components are Server vs Client is covered in depth in Next.js Deep Dive: the request lifecycle — this course teaches the React-level mechanism; a framework’s routing and rendering pipeline is the framework’s own territory.

cacheSignal() is a React 19.2 API for React Server Components: it returns an AbortSignal that aborts once React has finished rendering (so cache()-scoped work — a per-render fetch or database call — can be cancelled cleanly instead of finishing pointlessly after nobody needs the result anymore).

“cacheSignal allows you to know when the cache() lifetime is over… It will always return null in Client Components… If called outside of rendering, it returns null.” — react.dev, cacheSignal

src/__tests__/cacheSignalClientOnly.test.tsx
import { cacheSignal } from 'react';
test('cacheSignal() returns null when called outside Server Component rendering (a Client-only environment)', () => {
// This probe has no React Server Components runtime — exactly the "Client
// Component" case react.dev describes: cacheSignal() always returns null.
expect(cacheSignal()).toBeNull();
});
✓ cacheSignal() returns null when called outside Server Component rendering (a Client-only environment)

That’s the whole client-side story: cacheSignal is a signal for server-rendered cache() calls to observe, not something a Client Component reaches for.

use() does not start the fetch, and it does not cache anything. It reads a promise you give it — so that promise must be stable across renders. Creating a promise inside the component body means a new promise every render, which suspends forever (or refetches endlessly).

// ❌ New promise every render — refetches / never settles.
function Bad({ id }) {
const data = use(fetch(`/api/${id}`).then(r => r.json()));
// ...
}
// ✅ The promise is created outside and passed in (cached by the caller/framework).
function Good({ dataPromise }) {
const data = use(dataPromise);
// ...
}

This is why raw use() is almost always paired with a cache — either a framework’s data layer or a manual Map cache — that returns the same promise instance for the same request.

The pattern use() enables is render-as-you-fetch: kick off the request before or as you render (e.g. in a route loader, or on hover), pass the promise down, and let the component read it via use() inside a Suspense boundary. Contrast the three approaches:

flowchart TB
  subgraph old["fetch-on-render (useEffect)"]
    r1["render"] --> e1["effect runs"] --> f1["then fetch starts"] --> r2["re-render with data"]
  end
  subgraph new["render-as-you-fetch (use + Suspense)"]
    start["start fetch early
(loader / event)"] --> pass["pass promise down"]
    pass --> read["use(promise) suspends
→ Suspense fallback → data"]
  end
Fetch-on-render vs render-as-you-fetch

use() also reads context, and unlike useContext it can be called conditionally — handy for reading context inside a branch.

The diagram’s claim is that starting the fetch earlier saves real wall-clock time, not just “feels” faster. It’s easiest to see with the code-loading step made explicit: fetch-on-render can’t even start the fetch until the component’s own code has already loaded and mounted (so its effect can run), while render-as-you-fetch starts the data fetch in parallel with that code load, not after it.

src/__tests__/renderAsYouFetchTiming.test.tsx
function delay<T>(ms: number, value: T): Promise<T> {
return new Promise((resolve) => setTimeout(() => resolve(value), ms));
}
function loadComponentCode() {
return delay(20, 'component-code'); // simulates a lazy-loaded chunk
}
function fetchData(id: string) {
return delay(30, `data:${id}`);
}
test('fetch-on-render (sequential): code loads, THEN the mount effect starts the fetch', async () => {
const start = performance.now();
await loadComponentCode(); // component isn't mounted (so its effect can't run) until code arrives
await fetchData('1'); // effect starts the fetch only after mount
const elapsed = performance.now() - start;
expect(elapsed).toBeGreaterThanOrEqual(45); // ~20 + 30, sequential
});
test('render-as-you-fetch: the fetch starts in parallel with the code load, not after it', async () => {
const start = performance.now();
const dataPromise = fetchData('1'); // kicked off immediately (e.g. in a route loader)
await loadComponentCode(); // runs concurrently with the fetch above
await dataPromise; // by the time the component can use() it, it's already well underway
const elapsed = performance.now() - start;
expect(elapsed).toBeLessThan(40); // ~max(20, 30), not the sum
});

Real measured output from this exact file:

✓ fetch-on-render (sequential): code loads, THEN the mount effect starts the fetch ~54ms
✓ render-as-you-fetch: the fetch starts in parallel with the code load, not after it ~32ms

use() doesn’t solve races for you — if you build your own promise cache and the identity you’re fetching for changes quickly (fast typing in a search box, rapid navigation), an older, slower request can still resolve after a newer, faster one and overwrite it unless you cancel it. AbortController is the standard fix.

src/__tests__/raceAndAbort.test.tsx
function fetchSlow(id: string, ms: number, signal: AbortSignal): Promise<string> {
return new Promise((resolve, reject) => {
const t = setTimeout(() => resolve(`data:${id}`), ms);
signal.addEventListener('abort', () => {
clearTimeout(t);
reject(new DOMException('Aborted', 'AbortError'));
});
});
}
test('without abort: a slow first request resolves last and overwrites the newer result (the race)', async () => {
let latest = '';
const c1 = new AbortController();
const c2 = new AbortController();
const p1 = fetchSlow('first', 40, c1.signal).then((v) => { latest = v; });
const p2 = fetchSlow('second', 10, c2.signal).then((v) => { latest = v; });
await Promise.all([p1, p2]);
// 'second' resolves first (fast), then 'first' resolves later and clobbers it.
expect(latest).toBe('data:first');
});
test('with AbortController: aborting the stale request means it never overwrites the newer result', async () => {
let latest = '';
let controller = new AbortController();
async function request(id: string, ms: number) {
controller.abort(); // cancel whatever request is in flight
controller = new AbortController();
const { signal } = controller;
try {
const value = await fetchSlow(id, ms, signal);
latest = value;
} catch (err) {
if ((err as DOMException).name !== 'AbortError') throw err;
// aborted requests are silently dropped, not applied to state
}
}
const p1 = request('first', 40);
const p2 = request('second', 10);
await Promise.allSettled([p1, p2]);
expect(latest).toBe('data:second');
});
✓ without abort: a slow first request resolves last and overwrites the newer result (the race)
✓ with AbortController: aborting the stale request means it never overwrites the newer result

Here’s the honest part. Raw use() + Suspense + a hand-rolled promise cache is powerful but low-level. In real apps you almost always get render-as-you-fetch through a framework (a router/loader that fetches per route and provides the cache) or a data library like TanStack Query (which manages caching, revalidation, and Suspense integration for you). Learn use() to understand the mechanism; reach for a library to ship it.

Think of use() as a mailbox slot, not a phone call. useEffect-based fetching is like picking up the phone only after you’ve already walked into the room (mounted) — you can’t dial until you’re there. use() reading a promise is like checking a mailbox slot that someone else (a loader, a parent, a framework) already started filling before you arrived; if the letter (data) isn’t there yet, you wait (suspend) instead of dialing a fresh call yourself. The promise’s identity is the mailbox’s address — a new promise every render is a new, empty mailbox every time, never the one that’s actually being filled.

What you getBenefitCost
Raw use() + hand-rolled cacheFull control, no dependency, understand the mechanismYou own the cache, race handling, and invalidation yourself
A framework’s router/loaderFetch starts before the component even renders; cache is providedTied to that framework’s data-loading conventions
A data library (TanStack Query, etc.)Caching, revalidation, dedup, and Suspense integration out of the boxAnother dependency and its own mental model to learn
use(Context) over useContextWorks conditionally, inside if/loopsNot supported for reading context in Server Components
  • use() fetches data for you. It doesn’t — it only reads whatever promise you hand it. The fetch, the cache, and the retry-safety are entirely your (or your framework’s) responsibility.
  • Starting a fetch earlier only “feels” faster, but doesn’t actually save time. Proven above: kicking off the fetch in parallel with component code loading measurably shortens total wall-clock time versus waiting for mount before fetching.
  • use(Context) and useContext are interchangeable everywhere. They read the same context, but use() is allowed inside conditionals/loops and useContext is not — and unlike a promise, use(Context) is not supported in Server Components at all.

When reviewing generated data-fetching code that uses use(), check for:

  • Is the promise passed to use() built fresh inside the render body (use(fetch(...)) directly) instead of read from a stable cache? That suspends forever, per the section above.
  • When an identifier the fetch depends on (a search query, a selected id) can change quickly, is there any cancellation (AbortController) for the stale request — or can an older, slower response silently overwrite a newer one?
  • Does the code read context with use() inside a Server Component? react.dev is explicit that this isn’t supported — it needs to be a Client Component.
  • Is cacheSignal() used (or expected to return something useful) inside a Client Component? It always returns null there — it’s a server-only, RSC-specific API.

🐛 Spot the bug

function SearchResults({ query }) {
const [latestQuery, setLatestQuery] = useState(query);
const resultsPromise = fetchResults(query);
const results = use(resultsPromise);
return <ul>{results.map(r => <li key={r.id}>{r.title}</li>)}</ul>;
}
Show the answer

fetchResults(query) is called directly inside the render body, so it produces a brand-new promise on every render — the exact use()-needs-a-stable-promise bug. Beyond that, even if the promise were cached correctly, there’s no cancellation: if the user types quickly, an older request for an earlier query can resolve after a newer one and silently show stale results. The fix is two-fold: cache the promise per query (e.g. in a Map, keyed by the exact query string) so use() gets a stable reference, and cancel the previous in-flight request with AbortController whenever query changes, exactly as proven in the race/abort test above.

What happens when you pass a pending promise to use()?
How is use() different from hooks like useState?
Why must the promise passed to use() be cached / stable across renders?
In practice, how do most apps do render-as-you-fetch?
Can use(Context) be called from a Server Component?
function Panel({ userId }) {
  return <p>{use(UserContext)}</p>;
}
Two requests race: an older, slower one (40ms) and a newer, faster one (10ms) for the same piece of UI. Without any cancellation, which value ends up applied last?