The `use` Hook Is a Footgun: When to Reach for It in React 19 (and When to Walk Away)
React 19's `use` hook looks like a cleaner way to read promises and context. In production it has sharp edges. Here's where it earns its keep and where it quietly tanks your app.

The use hook shipped with React 19 and got framed as the friendly replacement for useContext plus a brand-new way to read promises directly in render. The marketing was tidy. The reality, after shipping it in a handful of Next.js App Router projects, is messier — and a lot more interesting.
This is the field guide we wish we'd had before the first production incident.
What use actually does
Unlike every other hook, use is allowed in conditionals and loops. It reads a resource — a Promise or a Context — and integrates with Suspense and error boundaries when that resource isn't ready.
import { use, Suspense } from 'react';
function UserBadge({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise);
return <span>{user.name}</span>;
}
export function Header({ userPromise }: { userPromise: Promise<User> }) {
return (
<Suspense fallback={<span>…</span>}>
<UserBadge userPromise={userPromise} />
</Suspense>
);
}
Two things are happening here. First, use suspends the component until the promise resolves. Second — and this is the part teams miss — the promise has to be created somewhere stable, or you'll re-suspend on every render.
The rule that bites you
If you write use(fetch('/api/me')) inside a Client Component, you've just created a new promise on every render. React will suspend, the parent re-renders, you create another promise, and so on. The component never settles.
The use hook does not memoize. It does not cache. It does not know what a promise "is" beyond its identity.
Where use actually shines
There are two patterns where use is genuinely better than what we had before.
1. Passing Server Component promises down to Client Components
This is the headline use case and it's worth the migration on its own. You can kick off a fetch on the server, hand the unresolved promise to a Client Component as a prop, and let the client unwrap it with Suspense.
// app/dashboard/page.tsx (Server Component)
import { Suspense } from 'react';
import { RecentOrders } from './recent-orders';
import { getOrders } from '@/lib/orders';
export default function DashboardPage() {
// No await here — we hand the promise down unresolved
const ordersPromise = getOrders();
return (
<section>
<h1>Dashboard</h1>
<Suspense fallback={<OrdersSkeleton />}>
<RecentOrders ordersPromise={ordersPromise} />
</Suspense>
</section>
);
}
// app/dashboard/recent-orders.tsx
'use client';
import { use } from 'react';
import type { Order } from '@/types';
export function RecentOrders({
ordersPromise,
}: {
ordersPromise: Promise<Order[]>;
}) {
const orders = use(ordersPromise);
return (
<ul>
{orders.map((o) => (
<li key={o.id}>{o.total}</li>
))}
</ul>
);
}
The server starts the fetch immediately. The HTML streams. The client hydrates and the same promise resolves on the client side. You avoid the classic waterfall where the client mounts, then asks for data, then renders.
This pattern alone is why we keep use in our toolbox.
2. Conditional context reads
Before React 19 you couldn't call useContext conditionally. So you'd read the context unconditionally and branch on the value. With use you can branch first:
function Price({ showLocale }: { showLocale: boolean }) {
if (!showLocale) return <span>$0</span>;
const locale = use(LocaleContext);
return <span>{formatPrice(0, locale)}</span>;
}
Niche, but real. We've used it to skip expensive context subscriptions inside lists where most rows don't need them.
The four footguns
This is where it gets uncomfortable.
Footgun 1: Creating promises in render
We already mentioned this but it deserves its own section because we've seen it ship to production at least three times.
// 🔥 Do not do this in a Client Component
function Profile() {
const user = use(fetch('/api/me').then((r) => r.json()));
return <div>{user.name}</div>;
}
Every render makes a new promise. Suspense throws, parent re-renders, new promise, infinite loop of network calls. Your /api/me endpoint will tell you about it before your monitoring does.
The fix is to create the promise once, outside the render path — usually in a Server Component, or via a cached function like React's cache() on the server, or a stable ref / store on the client.
Footgun 2: Error boundaries are not optional
When use reads a rejected promise, it throws the rejection during render. Without an error boundary above the component, the entire tree unmounts. We had a checkout flow where a flaky third-party promise rejection blanked the whole page because nobody had wrapped that subtree.
Rule of thumb: every Suspense boundary around a use call needs a sibling ErrorBoundary. We pair them in a small helper:
export function AsyncBoundary({
fallback,
errorFallback,
children,
}: {
fallback: React.ReactNode;
errorFallback: React.ReactNode;
children: React.ReactNode;
}) {
return (
<ErrorBoundary fallback={errorFallback}>
<Suspense fallback={fallback}>{children}</Suspense>
</ErrorBoundary>
);
}
Footgun 3: Suspense boundaries placed too high
use suspends the closest Suspense ancestor. If your boundary sits at the layout level, a single slow promise inside a deeply nested component can blank out the entire page region. We've watched a 600ms recommendations widget hold up an entire product page because the only <Suspense> was wrapping the whole <main>.
Keep boundaries close to the component that calls use. If you're not sure where, our take on streaming Suspense boundaries covers the placement heuristics.
Footgun 4: Client-side promise identity across renders
If you're using use on the client without a Server Component handing the promise down, you need a stable identity. Common sources of stable promises:
- A module-level promise (fine for app-wide singletons like config)
- A
useMemokeyed on something that genuinely changes when the request should change - An external store (Zustand, Redux, a query cache)
- React's
cache()on the server, never on the client
Be deliberate. "Where does this promise live?" should have a one-sentence answer for every use call in your codebase.
use vs the alternatives
A quick honest comparison.
vs await in a Server Component
If you don't need streaming and you don't need to pass anything to a Client Component, just await. It's simpler, the stack traces are better, and you don't pay the Suspense boundary tax.
vs TanStack Query / SWR
For client-side data with caching, refetching on focus, mutation invalidation, and devtools — use the library. use is a primitive, not a data layer. We've seen teams try to rebuild query caching on top of use and end up with a worse version of TanStack Query in three sprints.
vs useContext
If you're not reading the context conditionally, there's no reason to switch. useContext is fine. The lint rules around it are stricter and that's a feature.
A production checklist
Before you ship use to production, walk through this:
- Every
use(promise)call has a Suspense boundary close enough that loading states make UX sense. - Every Suspense boundary that contains
usehas an error boundary above it. - No promise is created during render in a Client Component without memoization.
- Promises passed from Server to Client are created without
awaitso they stream. - You've checked DevTools network panel for duplicate requests caused by re-suspending.
- Your error boundary fallback doesn't itself call
useon something that can fail.
That last one sounds silly until it happens to you.
Where we'd start
If you're adopting use on an existing Next.js App Router codebase, don't sweep through and rewrite contexts. The win there is marginal. Instead, find the three or four places where a Client Component fetches data on mount and causes a visible loading flash. Move the fetch to the parent Server Component, hand the unresolved promise down, and unwrap it with use inside a tight Suspense boundary. That's where you'll feel it on Core Web Vitals — particularly LCP and INP — and where the new mental model pays for itself.
Everything else can wait until you've got the boundary placement and error handling reflexes built. The hook is powerful. It's also unforgiving. Treat it that way and it'll earn its keep.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Partial Prerendering in Production: What Breaks When You Turn It On
Partial Prerendering looked like free performance on the demo slide. Then we shipped it. Here's what actually breaks when you flip the flag on a real Next.js app — and how we'd roll it out now.

Server Actions at Scale: The Hidden Cost of Treating Them Like API Routes
Server actions feel like free API endpoints. They aren't. Here's what we learned shipping them to production traffic, and the patterns that kept latency and bills in check.

Tokens, Not Themes: Migrating a React Design System to CSS Variables Without Breaking Production
A practical walkthrough of moving a React design system from styled-components themes to CSS custom properties and design tokens — without a big-bang rewrite or a Monday morning rollback.
