C.W.K.
Stream
Lesson 04 of 07 · published

The use() Hook — Inline Async Reads

~13 min · use, react-19, suspense

Level 0React Novice
0 XP0/54 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
use() is a small hook with a large blast radius. It lets components read promises as if they were synchronous values, with Suspense handling the wait. The win is that your component renders top-to-bottom in the success path; no loading state to thread through.

The shape

function MyComp({ promise }: { promise: Promise<T> }) {
  const data = use(promise);
  return <p>{data.value}</p>;
}

If the promise is pending, use() suspends — React shows the nearest Suspense fallback. If it rejects, use() throws — the nearest error boundary catches. If it's resolved, use() returns the value. The component body sees only the success case.

The cardinal rule — same promise across renders

use() recognizes a promise by reference identity. If you create a new promise inside the component on every render, use() sees a different (still pending) promise every time, suspends every time, and you get an infinite loading state.

The fix: create the promise outside the component (or in a parent and pass as prop, or cache it in a ref / context, or use a library like TanStack Query that handles caching).

Three sources of stable promises

  1. Parent prop — parent creates the promise (once or via cached factory), child consumes via use().
  2. Module-level constant — fine for one-off bootstrapping data.
  3. Cached factory — a function like cache((id: string) => fetch(id).then(j)) from React's cache helper (or your own Map-backed memoization).

use() with Context

use() also reads Context — and unlike useContext, it can be called conditionally. if (someCondition) { const value = use(MyCtx); } is legal with use() but illegal with useContext. Convenient when you have a Context that some branches don't need.

use() shifts the responsibility for where async lives. Pre-use(), each component carried its own loading state. Post-use(), the wait state lives in the Suspense boundary higher up, and the component reads the value as if it were already there. The leaf gets simpler; the parent takes on the promise-creation responsibility.

Code

use() with a parent-created promise·tsx
import { use, Suspense } from "react";

async function fetchUser(id: string): Promise<{ name: string }> {
  const r = await fetch(`/api/users/${id}`);
  if (!r.ok) throw new Error(r.statusText);
  return r.json();
}

function UserName({ userPromise }: { userPromise: Promise<{ name: string }> }) {
  const user = use(userPromise);
  return <p>{user.name}</p>;
}

function UserPage({ userId }: { userId: string }) {
  // Parent creates the promise. Each render of UserPage with the same userId
  // should yield the same promise — use a cache or pass through useState/useRef.
  const promise = fetchUser(userId); // simplified — see the cache pattern below for real use
  return (
    <Suspense fallback={<p>Loading…</p>}>
      <UserName userPromise={promise} />
    </Suspense>
  );
}
The cache pattern — stable promises across renders·tsx
import { use, Suspense } from "react";

// Map-backed cache so the same userId always returns the same promise.
const userCache = new Map<string, Promise<{ name: string }>>();

function getUserPromise(userId: string): Promise<{ name: string }> {
  if (!userCache.has(userId)) {
    userCache.set(
      userId,
      fetch(`/api/users/${userId}`).then((r) => r.json())
    );
  }
  return userCache.get(userId)!;
}

function UserName({ userId }: { userId: string }) {
  const user = use(getUserPromise(userId));
  return <p>{user.name}</p>;
}

function UserPage({ userId }: { userId: string }) {
  return (
    <Suspense fallback={<p>Loading…</p>}>
      <UserName userId={userId} />
    </Suspense>
  );
}

// In production you'd use TanStack Query or React's `cache()` helper
// to get the same effect with invalidation, retries, and deduping.
use() with Context — the conditional read·tsx
import { use, createContext } from "react";

const AuthCtx = createContext<{ user: { name: string } } | null>(null);

function MaybeGreeting({ greet }: { greet: boolean }) {
  // Conditional — useContext can't do this, use() can.
  if (greet) {
    const auth = use(AuthCtx);
    if (!auth) return null;
    return <p>Hello, {auth.user.name}</p>;
  }
  return null;
}

External links

Exercise

Build a <UserPage userId={...}> that uses the cache pattern shown. Confirm: (1) initial load suspends and shows the Suspense fallback; (2) once loaded, the data appears; (3) navigating to a different userId triggers another fetch + suspension; (4) navigating BACK to the original userId doesn't re-fetch (the cache hit returns the resolved promise). The cache makes navigation feel instant.
Hint
If repeat visits re-fetch, your cache is wrong. Check: is the Map module-level (preserved across renders) or local-to-component (recreated)? Module-level is what you want.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.