All articles
Web DevelopmentJuly 27, 2026 6 min read

The `use` Hook and Data Fetching in Client Components: Where It Helps and Where It Bites

React 19's `use` hook looks like the clean answer to promises in client components. It mostly is — until you hit the re-render trap, the Suspense boundary confusion, and the cache invalidation mess.

The use hook shipped in React 19 with a small, almost casual API. Pass it a promise, get the resolved value, let Suspense handle the loading state. On paper it deletes half your useEffect + useState fetching boilerplate. In practice, we've seen teams reach for it in client components and end up with worse behaviour than the code they replaced.

This is a field guide to where use actually helps in client components, and the three specific ways it will bite you if you drop it in without thinking.

What use actually does

use unwraps a promise (or a context) inside a component. If the promise is pending, the component suspends. If it rejects, the nearest error boundary catches it. If it resolves, you get the value synchronously from that point on.

'use client';

import { use } from 'react';

type Profile = { id: string; name: string };

export function ProfileCard({ profilePromise }: { profilePromise: Promise<Profile> }) {
  const profile = use(profilePromise);
  return <h2>{profile.name}</h2>;
}

Critically, use can be called conditionally, unlike other hooks. That's not a party trick — it's what makes it composable with early returns and branches. It also means it's the only sanctioned way to await-ish a promise inside a component render.

So far so good. The trouble starts when engineers assume use is a data fetching library. It isn't. It's a promise unwrapper. Where the promise comes from — and how long it lives — is entirely your problem.

The re-render trap

Here's the pattern we see most often in code review, and it's always wrong:

'use client';

import { use, useState } from 'react';

export function UserPanel({ userId }: { userId: string }) {
  const [tab, setTab] = useState<'overview' | 'activity'>('overview');

  // 🚨 New promise on every render
  const user = use(fetch(`/api/users/${userId}`).then(r => r.json()));

  return (
    <div>
      <button onClick={() => setTab('activity')}>Activity</button>
      {tab === 'overview' ? <Overview user={user} /> : <Activity user={user} />}
    </div>
  );
}

Every time this component renders — including when the user clicks a tab button — you create a brand new promise. React sees a new promise identity, suspends, refetches, and the panel flashes. You've built a component that fetches on every interaction.

use doesn't cache. It unwraps whatever promise you hand it. If the promise identity changes, you're starting over.

The fix: hoist or cache the promise

Two patterns work. The first is to create the promise outside the component that consumes it and pass it in as a prop:

// parent server component or a stable client parent
const userPromise = fetchUser(userId);

<UserPanel userPromise={userPromise} />

This works beautifully with the App Router: a server component starts the fetch, the promise is serialized across the RSC boundary, and the client component unwraps it with use. You get streaming for free.

The second, for genuinely client-originated fetches, is a stable cache keyed by the input:

'use client';

import { use, useMemo } from 'react';

const cache = new Map<string, Promise<User>>();

function getUser(userId: string) {
  if (!cache.has(userId)) {
    cache.set(userId, fetch(`/api/users/${userId}`).then(r => r.json()));
  }
  return cache.get(userId)!;
}

export function UserPanel({ userId }: { userId: string }) {
  const userPromise = useMemo(() => getUser(userId), [userId]);
  const user = use(userPromise);
  // ...
}

The module-level Map keeps promise identity stable across renders. useMemo isn't strictly required here since getUser returns the same reference for the same key, but it makes intent obvious.

At this point you've essentially reinvented a tiny query cache. If your app has more than a handful of these, stop and reach for TanStack Query or SWR. use is not a replacement for them; it's a lower-level primitive.

Suspense boundary placement matters more than you think

When a component calls use on a pending promise, React walks up the tree looking for a Suspense boundary. Whichever one it finds first is where your fallback renders — and everything inside that boundary unmounts and remounts when the promise resolves.

We had a client component that rendered a form with 12 fields and a <PriceEstimate use={pricePromise} /> widget in the corner. Someone forgot to add a Suspense boundary around just the widget. When users typed in the form, unrelated state updates triggered re-renders, and because the boundary was at the page level, the whole form suspended and lost focus.

// 🚨 Boundary too high
<Suspense fallback={<Skeleton />}>
  <BigForm />
  <PriceEstimate pricePromise={pricePromise} />
</Suspense>

// ✅ Boundary wraps only the suspending unit
<BigForm />
<Suspense fallback={<PriceSkeleton />}>
  <PriceEstimate pricePromise={pricePromise} />
</Suspense>

Rule of thumb: the Suspense boundary should be the smallest subtree that can meaningfully show a loading state without disrupting the rest of the page. If a user's focus, scroll position, or typing would be lost when the fallback shows — the boundary is too wide.

Error boundaries are not optional

use throws on rejection. Without an error boundary between the component and the root, a failed fetch crashes the entire tree. React does not have a default error UI. This is the second most common bug we see.

<ErrorBoundary fallback={<PriceError />}>
  <Suspense fallback={<PriceSkeleton />}>
    <PriceEstimate pricePromise={pricePromise} />
  </Suspense>
</ErrorBoundary>

Pair every use with both boundaries. Treat them as one unit.

Invalidation is still your problem

use gives you no revalidation story. If the underlying data changes — a mutation fires, a websocket pushes an update, the user hits a refresh button — nothing in use helps. You need to swap the promise for a new one.

In a Next.js App Router app, the cleanest path is a server action that revalidates, followed by an RSC re-render that produces a fresh promise:

'use server';

import { revalidateTag } from 'next/cache';

export async function updateUser(id: string, data: UserPatch) {
  await db.user.update({ where: { id }, data });
  revalidateTag(`user:${id}`);
}

The server component upstream re-renders, creates a new userPromise, ships it across, and the client component's use call unwraps the new value. No client cache to manage.

For purely client-driven invalidation, you're back to bumping a key on a parent component to force the promise to be recreated — or just using a real query library. Don't build a bespoke invalidation system on top of use. That road ends in a worse version of SWR that only you can maintain.

When to actually reach for use in client components

After shipping this pattern across several production apps, here's our short list of where use is genuinely the right tool:

  • Consuming a promise created by a server component. This is the killer use case. Start the fetch on the server, stream the promise, unwrap on the client. Zero waterfall.
  • Reading context conditionally. use(SomeContext) inside an if branch is legitimately useful and something useContext can't do.
  • A single, page-level fetch where the promise is created once at the top of the tree and consumed deep in a component that would otherwise need prop drilling.

Where it's the wrong tool:

  • Anything that needs caching, deduplication, or revalidation. Use TanStack Query or SWR.
  • Fetches triggered by user interaction inside the same component. Use a state machine or a mutation hook.
  • Anywhere the promise identity isn't stable across renders. You'll refetch forever.

Where we'd start

If you're adopting use on an existing App Router codebase, do it in this order. First, find places where a server component already fetches data and a client child needs the raw promise — convert those to pass Promise<T> as a prop and use it on the client. That's pure upside: less prop drilling, better streaming, no new failure modes.

Second, audit every existing use call and ask: where does this promise come from, and is its identity stable? If the answer is "a fetch() in the render body," fix it before it ships.

Third, don't rip out your query library. use and TanStack Query solve different problems. The teams that get this right treat use as a primitive for handing promises across the server/client seam, not as a general data fetching solution. If you want a hand auditing an App Router migration or tightening up your data layer, that's the kind of work our web development team does day-to-day.

#React 19#Next.js#Performance#Suspense#Client Components

Want a team like ours?

72Technologies builds production software for the kind of teams who actually read this blog.

Start a project