The first time use() clicked for me, I closed the tab on the React docs and immediately went through my codebase looking for useEffect calls to delete.
Three rewrites later, I reverted one. Here's what I learned about when use() is the right tool — and when reaching for it is the wrong instinct.
The setup
use() in React 19 lets you read the value of a promise inside a component. The component suspends while the promise resolves, and React handles the rest:
function UserCard({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise);
return <div>{user.name}</div>;
}
No useEffect. No useState. No if (loading) return <Spinner />. The component reads the value as if it were synchronous, and Suspense handles the loading state from a parent boundary.
It's beautiful. It's also not a drop-in replacement for every useEffect you've written.
Rewrite 1: the obvious win
This was the kind of pattern use() was designed for. A server component fetches data, passes the promise (not the awaited value) to a client component, and that component reads it:
// app/users/[id]/page.tsx (server component)
export default async function Page({ params }) {
const userPromise = fetchUser(params.id); // not awaited
return <UserCard userPromise={userPromise} />;
}
// UserCard.tsx (client component)
'use client';
import { use } from 'react';
export function UserCard({ userPromise }) {
const user = use(userPromise);
return <h1>{user.name}</h1>;
}
The parent server component starts the fetch and ships the promise. The client component reads it. There's a Suspense boundary somewhere above that handles the loading state.
The entire useState + useEffect + setUser dance I'd written for the old version evaporated. 18 lines down to 4. Lossless.
Rewrite 2: the slightly less obvious win
The second was for a config object that came from a context provider. The context's value was itself a promise (resolved lazily so the bundle didn't ship a megabyte of config), and consumers had been awkwardly awaiting it inside useEffect:
// Before
function Feature() {
const configPromise = useContext(ConfigContext);
const [config, setConfig] = useState<Config | null>(null);
useEffect(() => {
configPromise.then(setConfig);
}, [configPromise]);
if (!config) return null;
return <RealFeature config={config} />;
}
// After
function Feature() {
const configPromise = useContext(ConfigContext);
const config = use(configPromise);
return <RealFeature config={config} />;
}
use() works seamlessly with context — pass a promise through context, read the value with use() anywhere downstream. The component naturally suspends until the promise resolves.
Four lines. The if (!config) return null early return is gone, which means downstream types stop being Config | null and start being Config. Type-level win on top of the code-level win.
Rewrite 3: the one I reverted
Flush with confidence, I went after a useEffect that polled a status endpoint every 5 seconds:
// Before
function JobStatus({ jobId }) {
const [status, setStatus] = useState<Status>('pending');
useEffect(() => {
let cancelled = false;
const tick = async () => {
const next = await fetchJobStatus(jobId);
if (!cancelled) setStatus(next);
if (!cancelled && next === 'pending') {
setTimeout(tick, 5000);
}
};
tick();
return () => { cancelled = true; };
}, [jobId]);
return <StatusPill status={status} />;
}
My first attempt was to wrap each poll in a promise and read it with use(). It "worked" for the first fetch. Then I needed to re-poll. And cancel on unmount. And re-trigger when jobId changed.
Within 20 minutes I had reinvented useEffect poorly — a custom hook that kept track of the current promise, swapped it for a new one on each interval, and tore everything down on unmount. The code was longer, less readable, and had subtle bugs around the cleanup path.
I reverted. The useEffect version was correct and obvious.
The rule I derived
use() is for promises that resolve once and don't need cancellation.
That's it. That's the rule.
- Server-component-to-client-component data handoff: one resolution, never cancelled. ✅
- Lazy context value: one resolution, never cancelled. ✅
- Polling, websockets, subscriptions, debounced search: multiple resolutions, may need cancellation. ❌
The ones in the ❌ column still want useEffect — or a purpose-built hook like SWR or TanStack Query for the network-y ones. use() doesn't have a cancellation API because it isn't trying to be one. It's a primitive for reading, not for managing.
The meta lesson
New React features land and they look like silver bullets for ten minutes. They're not. They're sharp tools with narrow blades.
The time to reach for use() is when you have a single promise sitting in the right place at the right time and you're tired of writing 18 lines of state-and-effect glue to read it. The time to not reach for use() is when you've got lifecycle work — polling, subscriptions, cancellation, anything that lives over time.
Learn the shape of each new primitive. Don't refactor at scale until you've shipped one example end-to-end. And keep a git revert in your pocket — you'll need it the day after you discover something new.
If this helped, you can buy me a coffee. It keeps the writing coming.
Top comments (0)