DEV Community

Rahul Singh
Rahul Singh

Posted on • Originally published at aicodereview.cc

React 19 use() Hook: Guide to Promises and Context

Introduction

React 19, stable since December 2024, introduced one of the most significant API additions in the library's history: use(). After more than a year in production across thousands of applications, use() has fundamentally changed how React developers think about data fetching, asynchronous operations, and context consumption.

For years, the standard pattern for data fetching in React looked something like this: mount the component, fire off a useEffect, manage loading and error states with useState, and deal with cleanup and race conditions manually. Every component that needed server data repeated this same boilerplate. The result was verbose code, request waterfalls where child components had to wait for parents to render before initiating their own fetches, and an inconsistent user experience as different developers handled loading and error states differently.

The use() API solves these problems by letting you read the value of a resource -- specifically a Promise or a Context -- directly during render. When you pass a Promise to use(), React suspends the component until that Promise resolves, delegating loading states to the nearest Suspense boundary and errors to the nearest Error Boundary. The result is dramatically cleaner component code that separates the concern of "what data do I need" from "how do I show loading and error states."

One important clarification before we go further: use() is technically not a hook. The React team refers to it as an API. While it looks like a hook and is imported from react alongside hooks, it does not follow the rules of hooks. You can call use() inside conditionals, loops, and after early returns. This is a deliberate design choice that makes it uniquely flexible compared to useState, useEffect, and every other hook in React's API.

This guide covers everything you need to know about use() in 2026: how it works under the hood, practical patterns for both Promise and Context consumption, migration strategies from useEffect-based data fetching, advanced patterns with Server Components, and the common mistakes that trip up teams adopting it for the first time.

How use() Works

Syntax

The API surface of use() is deceptively simple:


const value = use(resource);
Enter fullscreen mode Exit fullscreen mode

The resource parameter accepts exactly two types:

  1. A Promise -- use() suspends the component until the Promise resolves, then returns the resolved value.
  2. A Context -- use() reads the current value of a React Context, similar to useContext().

That is the entire API. There are no options, no configuration objects, no generics to wrangle. The power comes from how use() integrates with React's existing Suspense and Error Boundary mechanisms.

Integration with Suspense

When you pass a Promise to use(), React does not wait inline for the Promise to resolve. Instead, it "suspends" the component. Suspension means React throws a special internal exception (you never see this yourself) that signals to the nearest
);
}
``tsx

This pattern inverts the traditional data fetching model. Instead of the component owning the fetch lifecycle (triggering it, tracking its state, cleaning it up), the component simply declares "I need this data" and React handles the rest.

Integration with Error Boundaries

If the Promise passed to use() rejects, React propagates the error to the nearest Error Boundary. This is the same mechanism React uses for render-time errors, so if you already have Error Boundaries in your application, they will catch rejected promises from use() automatically.

`tsx

function UserProfile({ userPromise }: { userPromise: Promise

);
}
`

Component Suspension Lifecycle

Here is exactly what happens when a component calls use() with a pending Promise:

  1. React begins rendering the component.
  2. The component calls use(promise).
  3. React checks whether the Promise has already resolved. If it has, React returns the value immediately and rendering continues.
  4. If the Promise is still pending, React throws a suspension signal.
  5. React walks up the component tree until it finds the nearest ); } tsx

Notice how UserCard contains zero state management. No useState for loading, no useState for error, no useEffect for triggering the fetch. The component is a pure function of its data.

Nested Suspense Boundaries

You can nest `

  <div className="grid">
    }>

    </Suspense>

    }>

    </Suspense>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

);
}


In this example, the header, posts, and stats sections each have their own Suspense boundary. Each section appears independently as its data arrives. The user sees the profile header the moment the user data loads, without waiting for posts or stats to finish. This eliminates the waterfall problem that plagues `useEffect`-based approaches.

### Error Handling with Error Boundaries

You can place Error Boundaries at different levels of granularity. A fine-grained approach lets one section fail without breaking the entire page:

Enter fullscreen mode Exit fullscreen mode


tsx

class SectionErrorBoundary extends Component<
{ children: ReactNode; section: string },
{ hasError: boolean }

{
state = { hasError: false };

static getDerivedStateFromError() {
return { hasError: true };
}

render() {
if (this.state.hasError) {
return (


Failed to load {this.props.section}.


this.setState({ hasError: false })}>
Retry


);
}
return this.props.children;
}
}

export default function Dashboard() {
const revenuePromise = fetchRevenue();
const ordersPromise = fetchRecentOrders();
const analyticsPromise = fetchAnalytics();

return (

  </SectionErrorBoundary>


  </SectionErrorBoundary>


  </SectionErrorBoundary>
</div>

);
}


If the analytics API fails, the revenue chart and recent orders still render normally. The analytics section shows its own error message with a retry button.

## use() with Context

### Reading Context with use() vs useContext

`use()` can read React Context, serving as an alternative to `useContext()`:


tsx

const ThemeContext = createContext<"light" | "dark">("light");

// With useContext (traditional)
function ThemedButtonOld() {
const theme = useContext(ThemeContext);
return Click me;
}

// With use() (new)
function ThemedButtonNew() {
const theme = use(ThemeContext);
return Click me;
}


For this simple case, the two approaches are functionally identical. The distinction matters when you need conditional context reading.

### The Key Difference: Conditional Context Reading

The `useContext` hook must be called at the top level of a component. You cannot put it inside an `if` statement, a loop, or after an early return. This is a fundamental rule of hooks that React enforces.

`use()` does not have this restriction. Because it is an API, not a hook, you can call it conditionally:


tsx

const ThemeContext = createContext<"light" | "dark">("light");
const AdminContext = createContext
);
}



tsx
// app/users/[id]/UserProfile.tsx (Client Component)
"use client";

interface User {
id: string;
name: string;
email: string;
bio: string;
avatarUrl: string;
}

export default function UserProfile({
userPromise,
}: {
userPromise: Promise
}>

  </Suspense>
</div>

);
}


### Pattern 3: Cache Layer with use()

To avoid recreating promises on every render, use a cache layer. React provides a `cache()` function for Server Components, and you can build your own for Client Components:


tsx
// Simple client-side cache for use()
const promiseCache = new Map>();

function cachedFetch
);
}

function UserListInner({ usersPromise }: { usersPromise: Promise
);
}

function UserDisplay({ userPromise }: { userPromise: Promise

);
}


The component body went from 45 lines to about 15. There is no state management, no cleanup function, and no conditional returns for loading and error states. Those responsibilities are handled declaratively by Suspense and Error Boundary.

### Step-by-Step Migration Guide

Here is how to migrate a `useEffect` data fetching component to `use()`:

**Step 1: Extract the fetch into a standalone function that returns a Promise.**

Move your `fetch` call out of the `useEffect` callback and into a regular function. Do not `await` -- just return the Promise.


tsx
// Before: fetch inside useEffect
useEffect(() => {
fetch(/api/users/${id}).then(res => res.json()).then(setUser);
}, [id]);

// After: standalone function
function fetchUser(id: number): Promise
);
}

// Consumer (reads the Promise with use())
function UserPage({ userPromise }: { userPromise: Promise
);
}


The trade-off here is that the entire dashboard waits for all three fetches to complete. If you want each section to appear independently, use separate Suspense boundaries with individual promises as shown in the earlier nested Suspense example.

### Streaming with use() and Suspense

In a Server Components architecture, `use()` enables progressive streaming. The server can start sending HTML for resolved components while still waiting for slower data sources:


tsx
// Server Component - Next.js App Router

export default async function ArticlePage({
params,
}: {
params: { slug: string };
}) {
// This resolves fast - article content from CDN
const article = await fetchArticle(params.slug);

// This is slow - comments from database
const commentsPromise = fetchComments(params.slug);

return (

{/* This renders immediately with the article content */}

  {/* This streams in when comments are ready */}
  }>

  </Suspense>
</main>

);
}



tsx
// Client Component
"use client";

export default function Comments({
commentsPromise,
}: {
commentsPromise: Promise


);
}

Enter fullscreen mode Exit fullscreen mode


tsx
// Client Component - needs interactivity (hover, zoom, click handlers)
"use client";

export default function InteractiveChart({
dataPromise,
}: {
dataPromise: Promise
);

// The fallback may briefly appear
expect(await screen.findByText("Jane Doe")).toBeInTheDocument();
});


### Testing Loading States

To test the Suspense fallback, use a Promise that you control when to resolve:

Enter fullscreen mode Exit fullscreen mode


tsx
test("shows loading skeleton while data is pending", async () => {
let resolvePromise: (value: User) => void;
const userPromise = new Promise
);

// Fallback should be visible while Promise is pending
expect(screen.getByTestId("skeleton")).toBeInTheDocument();

// Resolve the Promise
await act(async () => {
resolvePromise({
id: "1",
name: "Jane Doe",
email: "jane@example.com",
});
});

// Now the actual content should appear
expect(screen.getByText("Jane Doe")).toBeInTheDocument();
expect(screen.queryByTestId("skeleton")).not.toBeInTheDocument();
});


### Testing Error Boundaries

To test error handling, pass a rejected Promise and verify the Error Boundary renders:

Enter fullscreen mode Exit fullscreen mode


tsx
test("shows error message when fetch fails", async () => {
const failedPromise = Promise.reject(new Error("Network error"));

// Suppress React error boundary console.error in test output
const consoleSpy = jest.spyOn(console, "error").mockImplementation(() => {});

render(

</ErrorBoundary>
Enter fullscreen mode Exit fullscreen mode

);

expect(await screen.findByText("Something went wrong")).toBeInTheDocument();

consoleSpy.mockRestore();
});


### Testing Conditional Context with use()

When testing components that conditionally read context with `use()`, provide the context in your test wrapper:

Enter fullscreen mode Exit fullscreen mode


tsx
test("reads admin context when isAdmin is true", async () => {
const adminConfig = { secretDashboardUrl: "/admin/secret" };

render(

);
}


**Use `useMemo` for Client Component promise creation:**

Enter fullscreen mode Exit fullscreen mode


tsx
function ClientWrapper({ id }: { id: string }) {
// Only creates a new Promise when id changes
const dataPromise = useMemo(() => fetchData(id), [id]);

return (
}>

</Suspense>
Enter fullscreen mode Exit fullscreen mode

);
}


**Use route loaders** to create promises outside the component lifecycle entirely.

### Use Suspense Boundaries Strategically

Too few Suspense boundaries means a single slow fetch blocks your entire page. Too many creates a chaotic loading experience where dozens of skeletons pop in independently.

A good heuristic:

- **One Suspense boundary per independent content section.** A dashboard with revenue, orders, and analytics should have three boundaries.
- **Group tightly coupled data under one boundary.** A user header that shows both name and avatar should not have separate boundaries for each.
- **Place Suspense at layout boundaries.** Sidebar, main content, and footer are natural boundary points.

### Provide Meaningful Loading Fallbacks

Skeleton screens that match the layout of the loaded content are far better than generic spinners. They reduce perceived loading time and prevent layout shift:

Enter fullscreen mode Exit fullscreen mode


tsx
}>


Build skeleton components that match the dimensions and structure of the real content. Tools like `react-content-loader` can help, or you can build simple CSS-based skeletons with pulsing animations.

### Error Boundaries at Appropriate Granularity

Mirror your Suspense boundary structure with Error Boundaries. Each independent section should have its own Error Boundary so that one failed fetch does not take down the entire page:

Enter fullscreen mode Exit fullscreen mode


tsx
}>
}>


Include retry mechanisms in your Error Boundary fallbacks. A simple "Retry" button that resets the error state and triggers a new fetch goes a long way for user experience.

### Prefer Framework-Level Data Fetching

If you are using Next.js, Remix, React Router, or TanStack Start, use their built-in data loading primitives (loaders, server actions, `generateMetadata`) rather than rolling your own caching and fetch management. These frameworks handle cache invalidation, revalidation, and prefetching in ways that are difficult to replicate correctly in userland.

`use()` is the mechanism that makes these framework features work. You are often better off using `use()` indirectly through your framework's data layer than calling it directly with hand-crafted promises.

## Common Mistakes

### Creating Promises Inside the Component Body

This is mistake number one and it causes infinite re-renders:

Enter fullscreen mode Exit fullscreen mode


tsx
// BUG: new Promise every render -> infinite suspension loop
function Products() {
const products = use(fetchProducts()); // fetchProducts() creates a new Promise
return ;
}


The fix: create the Promise in a parent component, a route loader, a Server Component, or wrap it in `useMemo`.

### Missing Suspense Boundary

If there is no `

    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode


tsx

Missing Error Boundary

Without an Error Boundary, a rejected Promise from use() will crash your entire application. Always pair Suspense boundaries with Error Boundaries in production:

// BUG: rejected promise will crash the app
}>

</Suspense>

// FIX: Error Boundary catches rejected promises
}>
  }>

  </Suspense>
</ErrorBoundary>
Enter fullscreen mode Exit fullscreen mode

Using use() for Subscriptions

use() is designed for one-time reads of a Promise or Context. It is not a subscription mechanism. If you need to react to data that changes over time (WebSocket messages, real-time database updates, browser events), use useEffect with useState, or use a library like useSyncExternalStore:

// WRONG: use() reads a Promise once, it does not subscribe to changes
function LivePrice({ symbol }: { symbol: string }) {
  const price = use(fetchPrice(symbol)); // only gets the initial price
  return <span>{price}</span>;
}

// RIGHT: useEffect + useState for subscriptions
function LivePrice({ symbol }: { symbol: string }) {
  const [price, setPrice] = useState<number | null>(null);

  useEffect(() => {
    const ws = new WebSocket(`wss://prices.example.com/${symbol}`);
    ws.onmessage = (event) => setPrice(JSON.parse(event.data).price);
    return () => ws.close();
  }, [symbol]);

  return <span>{price ?? "Loading..."}</span>;
}
Enter fullscreen mode Exit fullscreen mode

Not Handling Race Conditions

When using use() with client-side promise creation, be aware that fast navigation between different items can cause stale data to appear if you do not invalidate previous promises:

// POTENTIAL BUG: if userId changes rapidly, stale promises may resolve out of order
function UserWrapper({ userId }: { userId: string }) {
  const userPromise = useMemo(() => fetchUser(userId), [userId]);

  return (
    }>

    </Suspense>
  );
}
Enter fullscreen mode Exit fullscreen mode

In most cases, React handles this correctly because each re-render with a new userId creates a new promise via useMemo, and React's Suspense mechanism discards the previous suspended render. However, for complex scenarios with nested suspense boundaries or transitions, consider using useTransition to manage the handoff:

function UserWrapper({ userId }: { userId: string }) {
  const [isPending, startTransition] = useTransition();
  const userPromise = useMemo(() => fetchUser(userId), [userId]);

  return (
    <div className={isPending ? "opacity-50" : ""}>
      }>

      </Suspense>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

React 19's use() API represents a genuine shift in how React applications handle asynchronous data and context. By integrating with Suspense and Error Boundaries, it eliminates the boilerplate that has plagued React data fetching for years and provides a declarative model where components describe what data they need rather than how to fetch it.

The key takeaways:

  • use() reads Promises and Context during render. It is an API, not a hook, and can be called in conditionals and loops.
  • Promises must be stable. Create them in Server Components, route loaders, or useMemo -- never inside the render body of the component that calls use().
  • Suspense handles loading, Error Boundaries handle errors. This separation of concerns keeps component code clean and moves UI state management to the component tree structure.
  • use() complements, not replaces, the existing ecosystem. useEffect is still the right tool for subscriptions and side effects. TanStack Query is still the right tool for complex cache management. use() is the right tool for one-time data reads at render time.
  • Frameworks make use() shine. The full power of use() comes when paired with Server Components and framework-level data loading in Next.js, Remix, or React Router.

If you are starting a new React 19 project, make use() your default approach for data fetching. If you are migrating an existing codebase, start by identifying useEffect-based data fetches that can be lifted to route loaders or Server Components, and migrate those first. The result will be cleaner, faster, and more maintainable code.

Further Reading

Frequently Asked Questions

What is the use() hook in React 19?

use() is a new React 19 API that lets you read the value of a resource (Promise or Context) during render. Unlike other hooks, use() can be called inside conditionals and loops, making it more flexible for conditional data fetching and context consumption.

How is use() different from useEffect for data fetching?

useEffect fetches data after render (causing loading states and waterfalls), while use() integrates with Suspense to fetch data during render. With use(), you pass a Promise to the component and React suspends rendering until the data is ready, showing a Suspense fallback automatically.

Can I call use() inside an if statement?

Yes. Unlike useState, useEffect, and other hooks that must be called at the top level, use() can be called inside conditionals, loops, and after early returns. This makes it uniquely flexible for conditional data dependencies.

Does use() replace useContext?

use() can read Context like useContext does, with the added benefit of being callable inside conditionals. However, useContext still works and is not deprecated. use() is preferred when you need conditional context access.


Originally published at aicodereview.cc

Top comments (0)