use() is the odd one out in React 19's hook lineup: it's the first hook that doesn't follow the Rules of Hooks. You can call it conditionally, inside a loop, or after an early return — something that would break the fiber's hook order if you tried it with useState or useEffect. That's possible because use() isn't tracking hook call order the way stateful hooks do; it's reading a value that's resolved elsewhere, either a promise or a context.
Why use() can break the rules
useState and friends rely on being called in the same order on every render, because React matches each call to a slot in the fiber's internal linked list purely by position. use() doesn't need that guarantee — reading a promise or a context value doesn't require persistent per-render slot tracking the way state does. That's what unlocks calling it inside an if block:
import { use } from 'react';
import { ThemeContext } from './theme-context';
function Banner({ messagePromise, show }: {
messagePromise: Promise<string>;
show: boolean;
}) {
if (!show) {
return null; // early return before any hook call — fine for use()
}
const theme = use(ThemeContext); // reading context
const message = use(messagePromise); // reading a promise
return <div className={theme.bannerClass}>{message}</div>;
}
The two things use() reads
First, promises: use(promise) suspends the component until the promise resolves, then returns the resolved value (or, if it rejects, throws so the nearest error boundary can catch it). This is what pairs with Suspense boundaries for data fetching without a library's own suspense-mode plumbing.
Second, context: use(SomeContext) reads the nearest provider's value, same as useContext(SomeContext) would — except use() can be called conditionally, which useContext never could. For a component that only needs a context value in one branch of a conditional, use() removes the awkwardness of calling useContext unconditionally at the top and then just not using the result in the other branch.
Reading a promise with use() doesn't give you a loading state to check the way a data-fetching hook's isLoading flag would — the component just suspends. Wrap it in <Suspense fallback=...> for the pending case, and an error boundary above that for the rejected case. Without both, an unresolved or failed promise takes down more of the tree than you intended.
Don't create a new promise on every render
The mistake that catches people first: calling use(fetch(url)) or any other expression that constructs a fresh promise inline. Every render creates a new promise, so React has no way to recognize "this is the same request, just check if it resolved" — it sees a brand-new unresolved promise every time and suspends again, indefinitely.
// Wrong: a new promise is created on every render — infinite suspend loop
function Bad({ id }: { id: string }) {
const data = use(fetch(`/api/items/${id}`).then((r) => r.json()));
return <p>{data.name}</p>;
}
// Right: the promise is created once, cached by id, and reused across renders
const cache = new Map<string, Promise<Item>>();
function getItem(id: string) {
if (!cache.has(id)) {
cache.set(id, fetch(`/api/items/${id}`).then((r) => r.json()));
}
return cache.get(id)!;
}
function Good({ id }: { id: string }) {
const data = use(getItem(id)); // stable promise identity per id
return <p>{data.name}</p>;
}
The stable promise can come from a module-level cache like the one above, a router loader that runs once per navigation, or a parent component that created it in response to an event (not during its own render). The common thread is that the promise's identity has to survive across the re-renders of the component calling use() on it.
Wrapping up
use() is less a "new way to fetch data" and more a new rule for how components read async values and context: called wherever you need it, including conditionally, but only ever fed a promise whose identity is stable across renders. Get that part right, wrap it in Suspense and an error boundary, and it composes cleanly with the rest of React 19's async-oriented feature set — Actions, transitions, and Suspense's own retry behavior all end up leaning on the same primitive.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.