React use() Hook: Resolving Promises Directly in Components
AI generated
</>
{ }
React 19 · Suspense · Hooks · Data Loading
React use() Hook
Resolving Promises Directly in Components

Loading data in React has always meant the same thing: useEffect, useState, a loading state, an error state, boilerplate. The use() hook from React 19 makes this declarative: a Promise goes in, the resolved value comes out, and Suspense takes care of the rest.

11 min read use() · Suspense · ErrorBoundary · Context · Server Components React 19+ · TypeScript · Next.js

1. The Boilerplate Problem of Data Loading

Loading data in React components has been associated with boilerplate since the very beginning. The standard pattern: useState for the data, useState for the loading status, useState for the error, and a useEffect that triggers the fetch, handles the cleanup case (AbortController), sets the states and correctly fills the dependency array. For a simple list of products this quickly adds up to 25 lines of boilerplate before the actual business logic even begins.

Libraries such as TanStack Query (React Query) and SWR have solved this problem at the application level. They abstract caching, refetching, background updates and error handling into reusable hooks. That is a genuine win, but it is also an external dependency for something that is conceptually very simple: wait for a Promise, show the resolved value. That is exactly the starting point for the use() hook in React 19.

Another problem with the classic approach: the loading state is managed locally in every component. When several components are loading at the same time, there are multiple spinners scattered across different parts of the page. Suspense addresses this problem at the structural level: a Suspense boundary coordinates the loading state for all child components and shows exactly one fallback while any child component is still loading. use() is the mechanism through which components opt into this Suspense system.

2. Suspense: Declarative Loading in React

Suspense was introduced in React 16.6, but for a long time it was limited to React.lazy. React 18 unlocked Suspense for data loading, but the official API was still missing. React 19 closes this gap: use() is the official API that lets a component "suspend", meaning it pauses its render until a Promise is fulfilled, without the developer having to implement a loading UI inside the component itself.

The principle: a component internally throws an exception (a Promise) while it is still loading. The nearest ancestor <Suspense> boundary catches this exception and renders the fallback instead. Once the Promise is fulfilled, React tries again to render the component, and this time use() returns the resolved value and the component renders normally. This throwing and retrying happens internally; the developer only sees what looks like a synchronous read of the Promise.

Several components under the same Suspense boundary coordinate their loading state together. If both UserProfile and OrderHistory sit under the same boundary, React waits until both are ready before rendering either. That prevents "popcorn UI", the visual flicker where individual components appear one after another. With nested Suspense boundaries, this behavior can be controlled deliberately.

3. use(): Syntax and Basic Behavior

use() has an unusual property among React hooks: it can be called conditionally. The classic rule "hooks must not be called inside conditionals" does not apply to use(). This is intentional: since use() waits on a Promise or a Context and does not manage state itself, there is no reason for the ordering restriction. So use() can be called inside if blocks, try blocks, or after early returns, which is particularly useful for Context reads.

The basic behavior: const data = use(somePromise) resolves the Promise synchronously (from the component's perspective) and returns the value. If the Promise is still pending, the component suspends. If the Promise rejects, the error is forwarded to the nearest ErrorBoundary. If the Promise has already been fulfilled (from cache), use() returns the value immediately without suspending. The behavior therefore depends on the state of the Promise, not on a separate loading state.


import { use, Suspense } from 'react';

interface User {
  id: number;
  name: string;
  email: string;
}

// Fetch function, returns a Promise, not the data directly
async function fetchUser(id: number): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json() as Promise<User>;
}

// Component reads the resolved value with use(), suspends if pending
function UserCard({ userPromise }: { userPromise: Promise<User> }) {
  // use() suspends the component until the Promise resolves
  const user = use(userPromise);

  return (
    <div className="border rounded-lg p-4">
      <h2 className="font-bold">{user.name}</h2>
      <p className="text-slate-600">{user.email}</p>
    </div>
  );
}

// Parent creates the Promise and passes it as a prop
export function UserPage({ userId }: { userId: number }) {
  // Promise is created OUTSIDE the component that uses it
  // (or at a stable reference point, do not create inside the child)
  const userPromise = fetchUser(userId);

  return (
    // Suspense boundary shows fallback while UserCard suspends
    <Suspense fallback={<div className="animate-pulse h-16 bg-slate-100 rounded-lg" />}>
      <UserCard userPromise={userPromise} />
    </Suspense>
  );
}

4. Resolving Promises with use()

The Promise that is passed to use() should be stable, meaning it should not be recreated on every render. If the Promise is recreated on every render, the component suspends again each time, leading to an infinite loading loop. The correct pattern: the Promise is created in the parent component (or fetched from a cache) and passed as a prop to the child component that calls use(). In Server Components the Promise can be created directly inside the component, since Server Components render exactly once per request.

In client components it is advisable to manage Promises in a cache or in a library such as TanStack Query. This prevents requests from being triggered again on re-renders. A simple alternative for less critical cases: store the Promise with useState in the parent component. useState initializes the state only once, so the Promise stays stable. const [userPromise] = useState(() => fetchUser(userId)), the factory argument of useState, only runs on the first render.

The caching pattern is critical for correct applications. When a component suspends with use() and re-renders after resolution, the Promise on the second render must be the same one as on the first, otherwise React fetches fresh data and the component suspends again. In Next.js, the framework takes care of this caching via the Server Components renderer. In pure client applications, a dedicated caching layer (TanStack Query, SWR, or a custom cache object) is necessary.

5. Error Handling with ErrorBoundary

When the Promise passed to use() is rejected (that is, it fails), use() forwards the error to the nearest ErrorBoundary. An ErrorBoundary is a class component that implements the componentDidCatch lifecycle and renders an error fallback when an error occurs. React has no built-in ErrorBoundary component, but there is a widely used library called react-error-boundary that provides this functionality.

The pattern for robust data loading components: every Suspense boundary is wrapped by an ErrorBoundary. The ErrorBoundary catches both errors from failed Promises (via use()) and render errors from child components. The ErrorBoundary's fallback can include a "retry" button that resets the ErrorBoundary and re-renders the component, which re-triggers the failed Promise.

A common misconception: try/catch inside a component does not catch errors thrown by use(). Since the throw happens internally within React (after the Promise rejection), try/catch in the component body has no effect. Error handling must always go through ErrorBoundaries. This is the same behavior as throw in render functions in general; React only catches render errors via ErrorBoundaries.


import { use, Suspense, useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';

interface Product {
  id: number;
  title: string;
  price: number;
}

async function fetchProducts(category: string): Promise<Product[]> {
  const res = await fetch(`/api/products?category=${encodeURIComponent(category)}`);
  if (!res.ok) throw new Error(`Failed to load products: ${res.status}`);
  return res.json() as Promise<Product[]>;
}

// use() suspends until products are loaded, errors bubble to ErrorBoundary
function ProductList({ productsPromise }: { productsPromise: Promise<Product[]> }) {
  const products = use(productsPromise);

  return (
    <ul>
      {products.map((p) => (
        <li key={p.id}>{p.title} ({p.price}€)</li>
      ))}
    </ul>
  );
}

// Error fallback with retry capability
function ErrorFallback({ error, resetErrorBoundary }: { error: Error; resetErrorBoundary: () => void }) {
  return (
    <div className="p-4 bg-red-50 border border-red-200 rounded-lg">
      <p className="text-red-700 font-semibold">Error while loading: {error.message}</p>
      <button onClick={resetErrorBoundary} className="mt-2 text-sm text-red-600 underline">
        Retry
      </button>
    </div>
  );
}

export function CategoryPage({ category }: { category: string }) {
  // Stable Promise reference, created once via useState factory
  const [productsPromise] = useState(() => fetchProducts(category));

  return (
    <ErrorBoundary FallbackComponent={ErrorFallback}>
      <Suspense fallback={<p className="text-slate-400">Loading products …</p>}>
        <ProductList productsPromise={productsPromise} />
      </Suspense>
    </ErrorBoundary>
  );
}

6. Reading Context with use()

use() can read not only Promises but also React Context. Unlike useContext, use(context) can be called conditionally, inside if blocks, after early returns, or inside loops. This is particularly useful when Context should only be read under certain conditions, without having to place the call at the very top of the component. Otherwise the behavior is identical to useContext: the component subscribes to the Context and re-renders when the Context value changes.

A practical example: a component that reads a user Context only when a requiresAuth prop is set. With useContext, the Context call must always be at the top, even if it is not needed in the current render pass. With use() it can be placed after the condition. This makes the code more readable and makes the conditional nature of the Context access explicit. This flexibility is the only difference from useContext; both are equivalent when the Context is always read.

In Server Components, use(context) cannot be used, Server Components cannot read Context (since Context is bound to a client render tree). To pass values to Server Components you have to use props or server-side mechanisms such as cookies and database queries. use(context) is intended purely for client components and behaves identically to useContext there, plus the conditional call flexibility.

7. use() vs. useEffect+useState Compared

The classic useEffect approach to data loading has fundamental weaknesses that use() addresses. The most important one: useEffect runs after the first render. That means the component always renders twice: once with an empty state (and an explicit loading spinner in the component itself), and once with the loaded data. use() suspends before the first render, so the component only renders once the data is ready, and the loading state lives exclusively in the Suspense boundary.

Criterion useEffect + useState use() + Suspense
Boilerplate 3 useState + useEffect + cleanup One hook call
Render phases Always at least 2 renders Directly with data
Loading UI Separately in every component Centralized in Suspense boundary
Error handling Manual error state Automatic via ErrorBoundary
Race conditions AbortController needed No race condition risk

Race conditions are a particularly insidious problem with the useEffect approach. If the user quickly switches between tabs and several requests are triggered, the responses can arrive out of order. The response that comes back last overwrites the current one, even though it originated from an older request. With use() and a stable Promise this is not a problem: new data is represented by a new Promise, and the old one is discarded.

8. Promise Caching: Why and How

When a component tree re-renders and a new Promise is passed to use() in the process, the component suspends again and shows the Suspense fallback. That is correct behavior, but in practice undesirable: you do not want to reload on every re-render. That is why a caching layer is essential. The goal: return the same Promise for the same data request as long as the data is still fresh.

The simplest hand-written cache implementation uses a Map that stores Promise objects keyed by a cache key. A function checks whether a Promise for the key already exists; if it does, it returns the existing one; if not, it creates a new one and stores it. This cache function is called in the parent component, and the returned Promise is passed to the child component with use(). For production applications, TanStack Query or a similar framework is the better choice, since it already provides caching, invalidation, background refetching and deduplication out of the box.

In Next.js with the App Router, the caching problem for Server Components is solved automatically: the Next.js framework deduplicates identical fetch() calls within a single render cycle and caches results via the fetch cache. Server Components using use() or a direct await take advantage of this caching automatically. In pure client applications or in Remix, an explicit caching strategy is necessary.