The use() Hook Changes Everything — A React 19 Production Guide

React 19's use() hook and useOptimistic API eliminate entire categories of boilerplate — no more useEffect data-fetching patterns or manual optimistic update tracking. Production guide with code examples.

React 19 use() hook visualization showing promise resolution and optimistic data flow

The use() Hook Changes Everything — A React 19 Production Guide

React 19 shipped with a deceptively simple API called use() that quietly rewires how the framework handles asynchronous data during render. Combined with useOptimistic for optimistic UI updates, these two hooks eliminate entire categories of boilerplate that frontend teams have been wrestling with since the days of useEffect data-fetching patterns. This guide covers production patterns, common pitfalls, and the mental model shift required to adopt them confidently.

Why use() Exists

Before React 19, reading a promise during render was discouraged — the framework would throw on unresolved promises. Reading context conditionally required render-prop patterns or custom hooks. The use() hook, inspired by the same proposal that powers <Suspense>, makes these operations first-class citizens.

use() accepts either a promise or a context object. With a promise, it suspends the component tree until resolution. With a context object, it reads the current value even inside conditional branches — unifying two patterns that previously required separate solutions.

Reading Promises During Render

The most dramatic shift is how data fetching works. Consider a component that loads user data and renders a profile:

import { use } from 'react';

async function fetchUser(id) {
  const res = await fetch(`/api/users/${id}`);
  return res.json();
}

function UserProfile({ userId }) {
  const user = use(fetchUser(userId));
  
  return (
    <article>
      <h1>{user.name}</h1>
      <p>{user.bio}</p>
    </article>
  );
}

The component suspends at the use() call until fetchUser resolves. A <Suspense> boundary upstream renders a fallback. No useState for loading states, no useEffect for triggering the fetch, no manual error handling in the component body. The framework manages the entire lifecycle.

This pattern works because React 19 treats promises as first-class render values. The framework tracks which promises each component has use()-d, and only resumes rendering after all of them resolve. Nested components can each use() their own promises, and React composes the suspension points correctly.

Conditional Context Access

Before use(), reading context conditionally was problematic. The rules of hooks required calling useContext at the top level, which meant you couldn't conditionally read different contexts based on runtime state. With use(Context), you can pass a context object directly and read it wherever you need it:

import { use } from 'react';
import { ThemeContext } from './theme';

function Button({ variant, children }) {
  const theme = use(variant === 'ghost' ? GhostThemeContext : ThemeContext);
  
  return (
    <button style={theme.buttonStyle}>
      {children}
    </button>
  );
}

This eliminates the need for wrapper components or render props that were previously the only way to achieve conditional context consumption. The React linter still enforces hook call ordering, but use() with a context object is treated as a stable hook call regardless of the argument's value.

useOptimistic for Flawless Optimistic UI

Optimistic updates have always been a source of boilerplate: track a pending state, revert on error, reconcile on success. useOptimistic collapses this into a declarative API:

import { useOptimistic, useState } from 'react';

function TodoList({ todos, onUpdate }) {
  const [optimisticTodos, setOptimistic] = useOptimistic(
    todos,
    (state, newTodo) => [...state, { ...newTodo, id: crypto.randomUUID() }]
  );
  
  const [isPending, startTransition] = useTransition();
  
  async function addTodo(title) {
    const newTodo = { title, id: crypto.randomUUID() };
    startTransition(async () => {
      setOptimistic(newTodo);
      await onUpdate(newTodo);
    });
  }
  
  return (
    <ul>
      {optimisticTodos.map(todo => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
}

The reducer function receives the current state and new value, returning the merged state. When the async operation completes, React reconciles back to the server-provided state. On failure, the optimistic state is discarded and the component re-renders with the original data.

Combining use() and useOptimistic

The real power emerges when you combine both hooks. A common pattern is loading a resource with use() while maintaining an optimistic local edit:

function EditableProfile({ userId }) {
  const user = use(fetchUser(userId));
  const [optimisticUser, setOptimistic] = useOptimistic(user);
  const [isPending, startTransition] = useTransition();
  
  async function updateName(name) {
    startTransition(() => {
      setOptimistic({ ...optimisticUser, name });
    });
    await fetch(`/api/users/${userId}`, {
      method: 'PATCH',
      body: JSON.stringify({ name }),
    });
  }
  
  if (isPending) {
    return <span>Saving...</span>;
  }
  
  return (
    <input 
      value={optimisticUser.name} 
      onChange={e => updateName(e.target.value)} 
    />
  );
}

This replaces three separate state variables, a useEffect for initial load, and manual error recovery. The framework handles suspension, optimistic updates, and reconciliation as a unified system.

Production Pitfalls

Despite the elegance, several gotchas trip up teams migrating to React 19. First, use() with promises only works inside components or custom hooks — not in module scope or event handlers. Attempting to use(fetchData()) inside a click handler will throw at runtime.

Second, every use()-d promise must have a <Suspense> boundary in the component tree above it. If you forget the boundary, React throws a "promise was not fulfilled" error during rendering. This is actually helpful during development but can cause confusing production errors if boundaries are missing in edge cases.

Third, useOptimistic does not handle server errors automatically. If the async operation inside startTransition rejects, the optimistic state persists until the component re-renders from an external source. You need explicit error handling — typically a try/catch around the async operation with a set call to restore the original state on failure.

Finally, the React Compiler automates memoization for use() results, but it does not memoize the promise creation itself. If you write use(fetchUser(id)) directly in the component body, a new promise is created on every render, causing unnecessary suspensions. Wrap the fetch in useMemo or extract it to a stable module-level function to avoid this.

When to Adopt

Teams using React 19 with the Compiler enabled should migrate data-fetching patterns to use() as a priority. The suspension-based model aligns better with how modern frameworks like Solid and Vue handle async rendering. useOptimistic should be adopted for any feature with manual optimistic updates — the API is simpler and less error-prone than the useState-plus-transition pattern it replaces.

For legacy code relying on useEffect-based fetching with complex retry logic, a gradual migration is safer: wrap the existing logic in a helper that returns a promise, then swap in use() once <Suspense> boundaries are in place.

Comments