#React#React 19#TypeScript#hooks#data-fetching

React 19's `use` Hook and Suspense: Patterns Worth Adopting Now

webhani·

React 19 shipped a deceptively small addition: the use hook. It is not a new concept — the idea of suspending a component while waiting for a promise has existed in React's experimental features for years. But use is now stable, and it changes how you should think about data fetching in client components.

This article walks through what use actually does at a mechanical level, the patterns that make it work well in practice, and where Server Components are still the better choice.

What use(promise) Actually Does

When you call use(promise) inside a component, React checks the state of the promise:

  • If the promise is pending, React suspends the component by throwing a special value that the nearest <Suspense> boundary catches. The fallback renders instead.
  • If the promise is resolved, React returns the resolved value immediately.
  • If the promise is rejected, React throws the error, which propagates to the nearest ErrorBoundary.

The key difference from useEffect is that use participates in React's rendering model directly. There is no intermediate state, no manual setLoading(false), and no cleanup function to manage race conditions by hand.

import { use } from "react";
 
type User = {
  id: string;
  name: string;
  email: string;
  avatarUrl: string;
};
 
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
  const user = use(userPromise);
  // By the time we reach this line, `user` is guaranteed to be resolved.
  return (
    <div className="profile">
      <img src={user.avatarUrl} alt={user.name} />
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

The component reads as if the data is always available. The loading state lives entirely outside it.

The Old Pattern and Its Problems

Before use, every data-fetching component looked roughly like this:

// React 18 and earlier
function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);
 
  useEffect(() => {
    let cancelled = false;
 
    fetchUser(userId)
      .then((data) => {
        if (!cancelled) {
          setUser(data);
          setLoading(false);
        }
      })
      .catch((err) => {
        if (!cancelled) {
          setError(err);
          setLoading(false);
        }
      });
 
    return () => {
      cancelled = true;
    };
  }, [userId]);
 
  if (loading) return <Spinner />;
  if (error) return <ErrorMessage error={error} />;
  return (
    <div className="profile">
      <img src={user!.avatarUrl} alt={user!.name} />
      <h2>{user!.name}</h2>
    </div>
  );
}

The problems here are real:

  • You manage three independent state variables that must stay in sync.
  • The cancelled flag is necessary to avoid setting state on unmounted components — but easy to forget.
  • Loading and error UI are mixed into the component that should only care about displaying data.
  • TypeScript forces non-null assertions (user!) because the types do not narrow past the null checks.

use eliminates all of this.

Basic Suspense Integration

The use hook requires a <Suspense> boundary somewhere in the tree above the component that calls it. When the promise is pending, React renders the fallback prop of the nearest Suspense instead.

import { use, Suspense, useMemo } from "react";
 
async function fetchUser(userId: string): Promise<User> {
  const res = await fetch(`/api/users/${userId}`);
  if (!res.ok) throw new Error(`Failed to fetch user: ${res.status}`);
  return res.json();
}
 
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
  const user = use(userPromise);
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}
 
function UserPage({ userId }: { userId: string }) {
  // Promise must be stable across renders — useMemo prevents recreation on every render
  const userPromise = useMemo(() => fetchUser(userId), [userId]);
 
  return (
    <Suspense fallback={<div className="skeleton h-24 w-full" />}>
      <UserProfile userPromise={userPromise} />
    </Suspense>
  );
}

The useMemo call is not optional. If you write const userPromise = fetchUser(userId) directly in the render body without memoization, a new promise is created on every render, which causes React to suspend indefinitely. Always create the promise outside the render or stabilize it with useMemo.

Passing Promises from Parent to Child

One of the cleaner design patterns that use enables is what the React team calls "lifting the promise." The parent creates the promise and passes it down; the child consumes it.

This is more than a stylistic choice. When you create multiple promises in the parent, they all start in parallel. There is no waterfall.

async function fetchUserPosts(userId: string): Promise<Post[]> {
  const res = await fetch(`/api/users/${userId}/posts`);
  if (!res.ok) throw new Error("Failed to fetch posts");
  return res.json();
}
 
function UserPostList({ postsPromise }: { postsPromise: Promise<Post[]> }) {
  const posts = use(postsPromise);
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>
          <a href={`/blog/${post.slug}`}>{post.title}</a>
        </li>
      ))}
    </ul>
  );
}
 
function UserDashboard({ userId }: { userId: string }) {
  // Both requests fire immediately, in parallel
  const userPromise = useMemo(() => fetchUser(userId), [userId]);
  const postsPromise = useMemo(() => fetchUserPosts(userId), [userId]);
 
  return (
    <div>
      <Suspense fallback={<ProfileSkeleton />}>
        <UserProfile userPromise={userPromise} />
      </Suspense>
      <Suspense fallback={<PostListSkeleton />}>
        <UserPostList postsPromise={postsPromise} />
      </Suspense>
    </div>
  );
}

Compare this to the useEffect approach, where you would typically start the second fetch only after the first one completes — a waterfall that adds latency proportional to the number of requests.

Error Boundaries in Practice

When a promise passed to use rejects, the error is thrown during render. Class-based ErrorBoundary components catch this and render a fallback UI.

import { Component, type ReactNode } from "react";
 
type Props = {
  children: ReactNode;
  fallback: (error: Error, reset: () => void) => ReactNode;
};
 
type State = { error: Error | null };
 
class ErrorBoundary extends Component<Props, State> {
  state: State = { error: null };
 
  static getDerivedStateFromError(error: Error): State {
    return { error };
  }
 
  reset = () => this.setState({ error: null });
 
  render() {
    if (this.state.error) {
      return this.props.fallback(this.state.error, this.reset);
    }
    return this.props.children;
  }
}
 
// Usage: ErrorBoundary wraps Suspense, not the other way around
function UserDashboard({ userId }: { userId: string }) {
  const userPromise = useMemo(() => fetchUser(userId), [userId]);
 
  return (
    <ErrorBoundary
      fallback={(error, reset) => (
        <div className="error-card">
          <p>Could not load user data.</p>
          <code className="text-sm">{error.message}</code>
          <button onClick={reset} className="mt-2">
            Retry
          </button>
        </div>
      )}
    >
      <Suspense fallback={<ProfileSkeleton />}>
        <UserProfile userPromise={userPromise} />
      </Suspense>
    </ErrorBoundary>
  );
}

Note the nesting order: ErrorBoundary wraps Suspense. If you invert them, the error will propagate past the Suspense boundary and may not be caught where you expect.

The reset callback is useful for retry flows — you can call it alongside re-creating the promise to trigger a fresh fetch attempt.

use Can Be Called Conditionally

This is worth highlighting because it breaks a rule that every React developer has internalized: hooks must not be called inside conditionals. use is the one exception.

function OptionalProfile({
  userId,
  userPromise,
}: {
  userId: string | null;
  userPromise: Promise<User> | null;
}) {
  if (!userId || !userPromise) {
    return <p>No user selected.</p>;
  }
 
  // This is valid — `use` can appear after an early return
  const user = use(userPromise);
  return <div>{user.name}</div>;
}

use also works with React Context, serving as an alternative to useContext with this same conditional-call flexibility:

import { use } from "react";
import { ThemeContext, type Theme } from "./ThemeContext";
 
function ThemedCard({ children }: { children: ReactNode }) {
  const theme = use(ThemeContext);
  return (
    <div
      style={{ backgroundColor: theme.surface, color: theme.text }}
      className="rounded-lg p-4"
    >
      {children}
    </div>
  );
}

When to Use Server Components Instead

If you are working in Next.js App Router, use is not always the right tool. Server Components let you await promises directly on the server, before any HTML is sent to the browser.

// app/users/[id]/page.tsx — Server Component
// No `use` hook needed. `await` works directly.
export default async function UserPage({
  params,
}: {
  params: { id: string };
}) {
  const user = await fetchUser(params.id);
  return <UserProfile user={user} />;
}

The decision framework:

SituationApproach
Static data, no client interaction neededServer Component with await
Data changes based on client state (user input, URL params on client)use hook + Suspense
Data triggered by a user action (button click, form submit)Server Action or fetch in event handler
Heavy computation or sensitive data (API keys, DB queries)Always Server Component

Using Server Components reduces JavaScript bundle size and avoids exposing fetch logic to the browser. Reserve use for cases where the data genuinely depends on client-side state.

Takeaways

The use hook is not a replacement for everything. It is a precise tool for one specific situation: a client component that needs asynchronous data, where the loading and error states should be handled declaratively by the component tree rather than imperatively inside the component.

When you adopt it, keep these in mind:

  • Always stabilize the promise with useMemo or create it outside the render function.
  • Create multiple promises in the parent to avoid waterfalls.
  • ErrorBoundary should wrap Suspense, not the reverse.
  • use can be called conditionally — this is intentional and useful.
  • In Next.js App Router, prefer Server Components for data that does not depend on client state.

The shift from useEffect + useState to use + Suspense is one of the cleaner ergonomic improvements in React 19. The code ends up shorter, more readable, and easier to reason about.