EdgeCases Logo
Mar 2026
React
Deep
7 min read

React 19 use() Hook: Promise Mechanics and Suspense Integration

The use() hook throws promises to trigger Suspense — but promise identity, conditional calls, and SSR streaming create unexpected behaviors.

react
react-19
use-hook
suspense
promises
ssr
streaming

React 19's use() hook reads values from promises and contexts. Unlike other hooks, it can be called inside conditionals and loops. But understanding how it works — throwing promises to trigger Suspense — reveals edge cases around promise identity, re-render behavior, and SSR streaming that can bite you in production.

How use() Actually Works: Throwing Promises

When you call use(promise) with a pending promise, React does something unusual: it throws the promise. React catches this thrown promise and hands it to the nearest <Suspense> boundary, which shows the fallback. When the promise resolves, React re-renders the component with the resolved value.

// Simplified mental model of use()
function use(promise) {
  if (promise.status === 'pending') {
    throw promise; // Triggers Suspense!
  }
  if (promise.status === 'rejected') {
    throw promise.reason; // Triggers Error Boundary
  }
  return promise.value; // Already resolved
}

This throw-based mechanism is why use() cannot be wrapped in try-catch — React needs to catch the thrown promise, not your error handler.

The Promise Identity Problem

Here's where things get tricky. Consider this code:

function UserProfile({ userId }) {
  // ❌ New promise created every render!
  const user = use(fetchUser(userId));
  
  return <div>{user.name}</div>;
}

If fetchUser(userId) returns a new promise on every call (which most fetch functions do), each re-render creates a new pending promise. React sees a new pending promise, throws it again, shows the fallback again — infinite loading loop.

Solution: Stabilize Promise Identity

// ✅ Create promise outside component, or use a cache
function UserProfile({ userPromise }) {
  const user = use(userPromise);
  return <div>{user.name}</div>;
}

// In parent:
const userPromise = fetchUser(userId); // Created once
<Suspense fallback={<Spinner />}>
  <UserProfile userPromise={userPromise} />
</Suspense>

Or with a caching layer:

// Simple cache for promise identity
const cache = new Map();

function getCachedUser(userId) {
  if (!cache.has(userId)) {
    cache.set(userId, fetchUser(userId));
  }
  return cache.get(userId);
}

function UserProfile({ userId }) {
  // ✅ Same promise returned for same userId
  const user = use(getCachedUser(userId));
  return <div>{user.name}</div>;
}

Conditional use(): Why It's Different

Unlike useState or useEffect, use() can be called conditionally:

function OptionalData({ shouldLoad, dataPromise }) {
  // ✅ Allowed! use() can be conditional
  if (shouldLoad) {
    const data = use(dataPromise);
    return <DataView data={data} />;
  }
  
  return <Placeholder />;
}

This works because use() doesn't rely on hook call order the same way other hooks do. It reads from the promise directly rather than storing state per position.

The Conditional Reveal Gotcha

But there's a subtle issue when conditionally revealing already-resolved promises:

function App() {
  const [show, setShow] = useState(false);
  
  // Promise resolves in 2 seconds
  const dataPromise = useMemo(
    () => new Promise(r => setTimeout(() => r('data'), 2000)),
    []
  );
  
  return (
    <>
      <button onClick={() => setShow(true)}>Reveal</button>
      {show && (
        <Suspense fallback="Loading...">
          <DataDisplay promise={dataPromise} />
        </Suspense>
      )}
    </>
  );
}

If you click "Reveal" after the 2-second timeout, you might expect immediate display. But React may still show the fallback briefly because the component wasn't mounted when the promise resolved — React needs to "catch up" and verify the promise state.

use() with Context: The Flexible Alternative

use() also reads context, offering flexibility useContext() lacks:

function ConditionalTheme({ useCustomTheme }) {
  // ✅ Context can be read conditionally!
  if (useCustomTheme) {
    const theme = use(ThemeContext);
    return <div style={{ background: theme.bg }}>...</div>;
  }
  
  return <div>Default theme</div>;
}

With useContext(), you'd have to call it unconditionally and then conditionally use the value. use() allows the read itself to be conditional.

Error Handling: No try-catch Allowed

Because use() throws promises (and errors), you cannot wrap it in try-catch:

function BadErrorHandling({ promise }) {
  try {
    // ❌ This breaks Suspense!
    const data = use(promise);
    return <DataView data={data} />;
  } catch (e) {
    // This catches the thrown promise, breaking React
    return <Error error={e} />;
  }
}

Instead, handle errors with Error Boundaries or Promise.catch():

// Option 1: Error Boundary
<ErrorBoundary fallback={<ErrorUI />}>
  <Suspense fallback={<Loading />}>
    <DataComponent promise={dataPromise} />
  </Suspense>
</ErrorBoundary>

// Option 2: Transform the promise
const safePromise = dataPromise.catch(err => ({ error: err }));
// Then check for error property in component

SSR Streaming: Server vs Client Behavior

In Server Components, prefer async/await over use():

// ✅ Server Component: use async/await
async function ServerProfile({ userId }) {
  const user = await fetchUser(userId);
  return <div>{user.name}</div>;
}

// Client Component receiving server promise
'use client';
function ClientProfile({ userPromise }) {
  const user = use(userPromise); // ✅ use() for passed promises
  return <div>{user.name}</div>;
}

Key difference: await in Server Components blocks rendering at that point. use() in Client Components allows streaming — the server sends the Suspense fallback immediately, then streams the resolved content.

Serialization Constraint

When passing promises from Server to Client Components, the resolved value must be serializable — no functions, symbols, or circular references:

// ❌ Functions can't be serialized
const badPromise = Promise.resolve({
  name: 'User',
  onClick: () => console.log('click') // Error!
});

// ✅ Only serializable data
const goodPromise = Promise.resolve({
  name: 'User',
  id: 123
});

Nested Suspense and Waterfall Prevention

Multiple use() calls create sequential loading by default:

function Dashboard() {
  const user = use(userPromise);     // Suspends
  const posts = use(postsPromise);   // Waits for user first!
  const stats = use(statsPromise);   // Waits for posts!
  
  return <DashboardView {...{user, posts, stats}} />;
}

Each use() throws and suspends before the next one runs. For parallel loading, start all promises before any use():

function Dashboard({ userId }) {
  // Start all fetches immediately (in parent or via cache)
  const userPromise = fetchUser(userId);
  const postsPromise = fetchPosts(userId);
  const statsPromise = fetchStats(userId);
  
  return (
    <Suspense fallback={<DashboardSkeleton />}>
      <DashboardContent 
        userPromise={userPromise}
        postsPromise={postsPromise}
        statsPromise={statsPromise}
      />
    </Suspense>
  );
}

When to Use use() vs Other Patterns

  • use(): For promises passed as props (especially from Server Components) or conditional context reads
  • React Query/SWR: For client-side fetching with caching, refetching, and stale-while-revalidate
  • Server Components + await: For server-side data that doesn't need client interactivity
  • useEffect: For side effects that shouldn't suspend the UI

use() is a low-level primitive. Most applications will use it indirectly through frameworks like Next.js or libraries that handle promise caching internally. Understanding the throwing mechanism helps debug when Suspense doesn't behave as expected.

Advertisement

Related Insights

Explore related edge cases and patterns

React
Deep
React Suspense Error Boundaries: Recovery Patterns
7 min
React
Deep
React Concurrent Rendering Tearing: When External Stores Break
7 min
React
Deep
React useId: Why Math.random() Breaks Hydration
7 min

Advertisement