React use(): A Promise Created in Render Suspends Forever
use(fetch(url)) looks like the Client Component version of await. It is not. use unwraps a specific Promise. A new Promise on every render is a new resource. The one from the last render never resolves for this call, so the nearest <Suspense> stays on its fallback. The network tab fills with requests. The spinner never leaves.
use is the React API that reads a Promise or a Context. It is not a Hook in the usual sense: you can call it inside if and inside a loop. The Promise still has to be the same Promise.
What use returns
Pass a Promise. While it is pending, use suspends the component. When it fulfills, use returns the fulfilled value. When it rejects, use throws the rejection reason.
"use client";
import { use } from "react";
export function OrderStatus({ statusPromise }: { statusPromise: Promise<string> }) {
const status = use(statusPromise);
return <p>{status}</p>;
}
That is the whole happy path. The Promise was created above this component. This file only unwraps it.
Pass a Context object and use returns the current context value, the same as useContext. The difference is the conditional call, which useContext does not allow:
function Label({ highlight }: { highlight: boolean }) {
if (!highlight) {
return <span>Plain</span>;
}
const theme = use(ThemeContext);
return <span style={{ color: theme.accent }}>Plain</span>;
}
Do not mix the two in your head. use(someContext) is context. use(somePromise) is data. A Context is not thenable.
The identity of the Promise is the cache
React does not fetch. It does not remember a URL. It associates the suspended tree with the Promise object you passed. Two fetches to the same URL are two Promises.
This is why the one-liner is broken:
"use client";
function OrderStatus({ id }: { id: string }) {
// New Promise every render. use() will not treat these as the same request.
const status = use(fetch(`/api/orders/${id}`).then((r) => r.json()));
return <pre>{JSON.stringify(status)}</pre>;
}
Each render — including the retry after suspend — calls fetch again. use sees a Promise it has never seen. The previous one is abandoned from the component's point of view. You can sit on the fallback until something else stops the loop.
The fix is to create the Promise once and pass it in. In the App Router, the place that can start work without being a Client Component is a Server Component. Server Components vs Client Components is the boundary. Start the request on the server, hand the Promise across as a prop, unwrap it on the client:
// app/orders/[id]/page.tsx — Server Component (no "use client")
import { Suspense } from "react";
import { OrderStatus } from "./order-status";
export default async function OrderPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const statusPromise = getOrderStatus(id);
return (
<Suspense fallback={<p>Loading status…</p>}>
<OrderStatus statusPromise={statusPromise} />
</Suspense>
);
}
async function getOrderStatus(id: string) {
const res = await fetch(`https://api.example.com/orders/${id}/status`, {
cache: "no-store",
});
if (!res.ok) {
throw new Error("status failed");
}
return res.text();
}
OrderStatus stays a Client Component if it needs state, an event handler, or browser-only APIs. It does not need to start the request to read the result. If the page does not need a Client Component at all, skip use and await in the Server Component. use exists for the case where the reader cannot be async.
On the server, cache from react dedupes a function for the rest of that request. Two Server Components that call cache(fn)(id) with the same id share one Promise. That is request memoization. It is not 'use cache' from the Next.js 16 Cache Components migration, and it does not persist across requests.
If you are already on the client and you must start the work there, store the Promise in a place that survives render: a parent useState initializer, a module-level Map keyed by id, or a library that caches the thenable. Do not put fetch in the argument list of use.
Suspense is the pending state. An Error Boundary is the rejection.
use does not return a { status, data, error } object. Pending is suspend. Rejection is throw.
A tree that calls use(promise) without a <Suspense> above it has no fallback. A rejection without an Error Boundary is an unhandled error in that subtree. <Suspense> does not catch a rejected Promise. try / catch around use does not catch it either — the throw is how suspend and reject are implemented, and a try around use is the wrong tool.
<ErrorBoundary fallback={<p>Could not load status.</p>}>
<Suspense fallback={<p>Loading status…</p>}>
<OrderStatus statusPromise={statusPromise} />
</Suspense>
</ErrorBoundary>
ErrorBoundary is not an export from react. It is a class component you write (or a thin wrapper around one) that implements getDerivedStateFromError or componentDidCatch. Put it around the part of the page that can fail, not around the whole layout, or one rejected widget blanks the chrome. The fallback is the UI for that hole. When the Promise fulfills, React retries the subtree and use returns the value.
This is a different path than useOptimistic. Optimistic UI is for a mutation you already know the likely result of. use is for a Promise that already exists. Do not layer them until both sides are obvious: confirmed data from use or from props, optimistic overlay from the hook.
params and searchParams are Promises too
In current Next.js App Router pages, params and searchParams are Promises. A Client Component does not await. It either receives already-resolved values from a Server Component parent, or it unwraps with use:
"use client";
import { use } from "react";
export function FilterBar({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const params = use(searchParams);
const q = typeof params.q === "string" ? params.q : "";
return <input defaultValue={q} />;
}
Same rule. The Promise comes from the framework. You do not construct it. You do not recreate it when the input changes.
What this API is not
Not useEffect plus useState. An effect that fetches after paint is a different model: you render empty, then swap. use plus Suspense means the first paint of that subtree is the fallback, then the data. Pick one. Do not use a Promise that you also kick off in an effect. You will race yourself.
Not a place to put side effects. use(promise) is a read. Logging, analytics, and writes belong after the user did something, or on the server when you already have the result.
Not a reason to make every leaf a Client Component. If the child only displays text, keep it a Server Component and await. Crossing the client boundary just to call use adds a bundle and a waterfall you did not need.
Not useContext with extra steps, except the conditional. If the call is unconditional, useContext is still the clearer name. Use use(context) when an early return would otherwise violate the Rules of Hooks.
The failure mode that ships is the spinner that never ends. The cause is almost always a Promise allocated in the same render that called use. Create it once, above the call, wrap pending in <Suspense>, and put rejection on an Error Boundary. Then use is a read, not a loop.
Keep reading
Next.js 16 Cache Components: Replacing revalidate and force-dynamic With 'use cache'
How Cache Components in Next.js 16 replace route segment configs with the use cache directive and cacheLife, what becomes a build error, and the cookies-outside-the-cache rule that trips every migration.
React List Keys That Survive a Filter
Why key={index} corrupts state when a list is filtered, sorted, or prepended, and which id to use instead.
React 19 useOptimistic: Instant UI Without Lying to the Server
How to wire useOptimistic with Actions so toggles and comments feel native, roll back on failure, and stay consistent when two mutations overlap.
Server Components vs. Client Components: A Mental Model That Sticks
The hardest part of React Server Components isn't the syntax — it's knowing which kind of component you're writing and why. Here is the mental model that makes the boundary obvious.
Optimistic UI Updates: Making Apps Feel Instant Without Lying to Users
The like button that responds before the server confirms feels instant. The trick is updating the UI first and reconciling later — and handling the rollback so you never mislead the user.
Debouncing and Throttling in React: Taming Expensive Event Handlers
A search box that fires a request on every keystroke, a scroll handler running 60 times a second — these are debounce and throttle problems. The concepts are simple; React makes them subtly tricky.
Newsletter
New posts, straight to your inbox
One email per post. No spam, no tracking pixels, unsubscribe anytime.
Comments
- No comments yet. Be the first.