use() & Data Fetching
The React 19 use() API
Section titled “The React 19 use() API”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.
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 ifuse() 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
useis 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 (React 19.2, server-only)
Section titled “cacheSignal (React 19.2, server-only)”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).
“
cacheSignalallows you to know when thecache()lifetime is over… It will always returnnullin Client Components… If called outside of rendering, it returnsnull.” — react.dev,cacheSignal
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.
The critical rule: cache the promise
Section titled “The critical rule: cache the promise”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.
Render-as-you-fetch
Section titled “Render-as-you-fetch”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 use() also reads context, and unlike useContext it can be called conditionally — handy for reading context inside a branch.
Proving the timing difference
Section titled “Proving the timing difference”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.
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 ~32msRace conditions and abort
Section titled “Race conditions and abort”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.
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 resultYou usually won’t write this by hand
Section titled “You usually won’t write this by hand”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.
Mental Model
Section titled “Mental Model”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.
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
Raw use() + hand-rolled cache | Full control, no dependency, understand the mechanism | You own the cache, race handling, and invalidation yourself |
| A framework’s router/loader | Fetch starts before the component even renders; cache is provided | Tied to that framework’s data-loading conventions |
| A data library (TanStack Query, etc.) | Caching, revalidation, dedup, and Suspense integration out of the box | Another dependency and its own mental model to learn |
use(Context) over useContext | Works conditionally, inside if/loops | Not supported for reading context in Server Components |
Common Misconceptions
Section titled “Common Misconceptions”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)anduseContextare interchangeable everywhere. They read the same context, butuse()is allowed inside conditionals/loops anduseContextis not — and unlike a promise,use(Context)is not supported in Server Components at all.
Reading AI-generated code
Section titled “Reading AI-generated code”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 returnsnullthere — 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.