Back to all notes

use() with Promises : Async Data with Suspense in React 19

Pass a promise to use() and let Suspense handle loading and rendering.

2 min read

I’ve written this fetch component too many times: useEffect plus useState, a loading flag, an error state, the data itself, and a cancellation guard against races. React 19 lets use() take a promise directly. React suspends while it’s pending and resumes when it resolves, so a Suspense boundary can own the waiting UI.

Three states and a cancelled flag

The old shape. Fetch in an effect, guard against unmount with cancelled, then branch on loading, error, and message before rendering anything:

import { useState, useEffect } from "react";

function fetchMessage() {
  return new Promise((resolve) => {
    setTimeout(() => resolve("Hello from the promise!"), 2000);
  });
}

function Message() {
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [message, setMessage] = useState("");

  useEffect(() => {
    let cancelled = false;
    fetchMessage()
      .then((m) => !cancelled && setMessage(m))
      .catch((e) => !cancelled && setError(e))
      .finally(() => !cancelled && setLoading(false));
    return () => {
      cancelled = true;
    };
  }, []);

  if (loading) return <p>⌛ Loading message...</p>;
  if (error) return <p>Something went wrong</p>;
  return <p>Here is the message: {message}</p>;
}

function App() {
  return (
    <div>
      <h1>Use Hook with Promises</h1>
      <Message />
    </div>
  );
}

export default App;

use() inside a boundary

The same feature, restructured. The parent creates the promise once with useMemo, the child reads it with use(), and Suspense supplies the fallback:

import { use, Suspense, useMemo } from "react";

function fetchMessage() {
  return new Promise((resolve) => {
    setTimeout(() => resolve("Hello from the promise!"), 2000);
  });
}

function Message({ messagePromise }) {
  const messageContent = use(messagePromise);
  return <p>Here is the message: {messageContent}</p>;
}

function App() {
  const messagePromise = useMemo(() => fetchMessage(), []);

  return (
    <div>
      <h1>Use Hook with Promises</h1>
      <Suspense fallback={<p>⌛ Loading message...</p>}>
        <Message messagePromise={messagePromise} />
      </Suspense>
    </div>
  );
}

export default App;

While the promise is pending, Message suspends and the fallback shows. On resolve, use(messagePromise) hands back the resolved value and rendering finishes. The useMemo is load-bearing: recreating the promise every render would keep the component suspending.

Where the promise comes from

Create the promise above the component whenever possible, at the route or loader level, and pass it down as a prop. Rejections need an error boundary paired with the Suspense boundary; use() doesn’t handle those itself.