Skip to content

React 19 use() Hook: The Pattern I Actually Reach For Now

React 19 use() Hook: The Pattern I Actually Reach For Now

Okay, confession: I ignored the use() hook for six months after React 19 shipped. I read the announcement, saw “you can await promises in components”, muttered “cool” out loud, and then went right back to writing the same useEffect + useState + if (loading) return null dance I’ve written a hundred times. It felt like a party trick.

Then I actually tried it on a real feature. Now I keep reaching for it, kind of by accident. This post is what I wish someone had told me before I finally sat down with the React docs on use.

What use() actually does

The one-line version: use() unwraps a promise or a context inside your render, and lets Suspense above it show the fallback while the promise is pending. That’s it. There’s no new async render mode, no magic. Under the hood it’s the same Suspense mechanism you’ve been ignoring in the Next.js docs.

Two things make it feel different from every other hook. You can call it inside if blocks and loops (rules of hooks got relaxed for this one), and it’s not just for data fetching. Reading context conditionally is the other big win, and I’ll get to that in a minute.

The React 19 release post is where they announce it, and it’s short enough that I wish I’d actually read it in December.

The useEffect dance I was writing

Here’s the thing I was doing until embarrassingly recently:

function InvoiceView({ id }) {
  const [invoice, setInvoice] = useState(null);
  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    let cancelled = false;
    setLoading(true);
    fetch(`/api/invoices/${id}`)
      .then((r) => {
        if (!r.ok) throw new Error(`HTTP ${r.status}`);
        return r.json();
      })
      .then((data) => { if (!cancelled) setInvoice(data); })
      .catch((e) => { if (!cancelled) setError(e); })
      .finally(() => { if (!cancelled) setLoading(false); });
    return () => { cancelled = true; };
  }, [id]);

  if (loading) return <Spinner />;
  if (error) return <ErrorBox error={error} />;
  return <InvoiceBody invoice={invoice} />;
}

Twenty three lines to say “get the invoice, show a spinner while it’s coming, and don’t set state after unmount”. I’ve written this pattern, or something painfully close to it, in every React app I’ve touched since 2019. The cancelled flag is there because in strict mode dev, effects run twice and you don’t want to overwrite fresh data with stale data. If you forget the flag, you eat a bug that only shows up once every three deploys.

After: use() with a Suspense boundary

Here’s the same feature with use():

// The promise-returning function. Nothing React-specific.
function getInvoice(id) {
  return fetch(`/api/invoices/${id}`).then((r) => {
    if (!r.ok) throw new Error(`HTTP ${r.status}`);
    return r.json();
  });
}

function InvoiceView({ id }) {
  const invoice = use(getInvoice(id));
  return <InvoiceBody invoice={invoice} />;
}

function InvoicePage({ id }) {
  return (
    <Suspense fallback={<Spinner />}>
      <ErrorBoundary fallback={<ErrorBox />}>
        <InvoiceView id={id} />
      </ErrorBoundary>
    </Suspense>
  );
}

The spinner lives in the tree, not inside the component. The error UI lives in an error boundary, not inside the component. The component just says “I need an invoice, give me one”.

There’s a catch worth calling out. If you create the promise inside the component (use(getInvoice(id)) on every render), you’ll re-run the fetch on every re-render unless you cache it. In practice you almost always feed use() a promise that came from a stable source: a server component, a cached loader function, or a memoised value. I’ve been burned by this exactly once, on a search input, and it was very fast and very expensive.

The other thing use() does: conditional context

The part I somehow didn’t notice for months: use() also reads context. And unlike useContext, you can call it in a branch.

Before:

function PriceBadge({ locale }) {
  // If we don't need currency here, we still subscribe to it.
  const currency = useContext(CurrencyContext);

  if (!locale) return null;
  return <span>{format(currency, locale)}</span>;
}

After:

function PriceBadge({ locale }) {
  if (!locale) return null;
  const currency = use(CurrencyContext);
  return <span>{format(currency, locale)}</span>;
}

The dumb version of why this matters: useContext at the top means every provider change re-renders your component, even when you were going to bail early anyway. With use() inside the branch, the subscription only exists when it’s actually needed. On a page with a few hundred badges and a CurrencyContext that changes when the user switches locale, that’s the difference between a snappy switch and a noticeable jank.

It’s a small win. But it’s the kind of small win I care about, because I’ve spent enough time in React Suspense in production to know that “one context subscription per render” adds up on complex pages.

Where use() falls over

I’m not going to pretend this replaces everything.

For mutations, use() is for reading. If you’re posting a form, you want useActionState or a server action. Trying to shove a mutation into use() is like using a bookmark as a hammer.

For chatty client-side fetches, if the component itself is creating the promise and you don’t have a caching layer, you’ll refetch on every re-render. Pair use() with something that memoises the promise. On the client that usually means React Query or SWR. On the server it usually means React’s own request-scoped cache.

Obvious but worth saying: this is a React 19 thing. If you’re on 18 and can’t upgrade this quarter, you don’t get it. The rest of your team is not going to accept “we need to upgrade React for one hook”.

And if you’re deep inside a bespoke rendering setup with no Suspense boundary and no plans to add one, use() is going to be more trouble than the useEffect you already have.

Where I’m using it right now

Two places, honestly.

Route-level data loading in a small Next.js app I’m rebuilding. The layout has the Suspense boundary, each page component calls use() on the loader. No loading.jsx files, no if (loading) return null. The code deleted itself, and the new version is easier to read a month later.

Conditional context reads inside heavy list rows. Feature-flag context, theme context, and a currency context that’s basically read-mostly. All the “only subscribe if you’re going to render” cases. On one dashboard we shaved a noticeable chunk off the re-render time for a table of 400 rows just by moving three useContext calls behind an early return.

Where I’m not using it: forms, mutations, anywhere I need optimistic updates. That’s useActionState and useOptimistic territory, and I’ll write about those separately once I’ve shipped enough of them to have opinions worth sharing. So far the pattern I like most is passing the server action straight into useActionState, letting it own the pending state, and never touching useState for form fields at all. That’s a post for another Tuesday.

What to do this week

Pick one component in your codebase that does the useEffect + useState + “if loading return null” dance and rewrite it with use(). Just one. Wrap it in a Suspense boundary and an error boundary. Notice what got deleted. Notice what got harder. Both matter.

If you want a longer read on how I think about Suspense boundaries in general (where to place them, how granular to go), that’s what my React Suspense in production writeup was about. And if you’re the kind of person who likes to see a whole app built around these ideas, I keep a handful of them on my portfolio.

That’s the pattern. Try it on something small first.