Exploring React's `use` hook

April 29, 2024

A new feature coming in React 19 is the use function. The use function allows a component to read from a "resource" in render. The stated use cases of a resource (for now) are: reading from context (a replacement for useContext) and reading the value from a Promise. The benefit of using use instead of useContext is use can be called conditionally whereas useContext cannot as it must follow the rules of hooks. While reading from context conditionally seems useful, reading from a promise in render is what really caught my attention. As a heavy user of Tanstack Query, I was curious if this could replace client-side data fetching libraries. Unfortunately, there's a note that reads:

use does not support promises created in render ... you need to pass a promise from a suspense powered library or framework that supports caching for promises.

The docs don't mention what "suspense powered" means but how hard could it be to cache a Promise? Let's find out.

Working with use

First, let's dig into the types of use.

function use<T>(usable: Usable<T>): T;

Now what's a Usable?

type Usable<T> = Thenable<T> | Context<T>;

Okay, so a Usable is a Thenable or a Context. We are familiar with Context but what's a Thenable?

type Thenable<T> =
	| UntrackedThenable<T>
	| PendingThenable<T>
	| FulfilledThenable<T>
	| RejectedThenable<T>;

interface ThenableImpl<T> {
	then(
		onFulfill: (value: T) => unknown,
		onReject: (error: unknown) => unknown,
	): void | PromiseLike<unknown>;
}
interface UntrackedThenable<T> extends ThenableImpl<T> {
	status?: void;
}

interface PendingThenable<T> extends ThenableImpl<T> {
	status: "pending";
}

interface FulfilledThenable<T> extends ThenableImpl<T> {
	status: "fulfilled";
	value: T;
}

interface RejectedThenable<T> extends ThenableImpl<T> {
	status: "rejected";
	reason: unknown;
}

A Thenable looks much like a Promise but with added properties to make the status of the promise observable. Like a Promise, a Thenable can be in one of three states: pending, fulfilled, or rejected with the additional "untracked" type for a Promise that hasn't been converted to a Thenable.

Implementing a Thenable

At first I thought we would have to implement Thenable ourselves. The implementation is relatively simple:

const createThenable = <T>(promise: Promise<T>): Thenable<T> => {
	const pendingThenable: PendingThenable<T> = {
		then: promise.then.bind(promise),
		status: "pending",
	};

	promise
		.then((value) => {
			const fulfilled = pendingThenable as unknown as FulfilledThenable<T>;
			fulfilled.then = () => {};
			fulfilled.status = "fulfilled";
			fulfilled.value = value;
		})
		.catch((reason) => {
			const rejected = pendingThenable as unknown as RejectedThenable<T>;
			rejected.then = () => {};
			rejected.status = "rejected";
			rejected.reason = reason;
		});

	return pendingThenable as Thenable<T>;
};

This function takes a Promise and returns a Thenable. The Thenable starts in the pending state and when the Promise resolves, the Thenable transitions to the fulfilled state with the resolved value. If the Promise rejects, the Thenable transitions to the rejected state with the rejection reason.

But it turns out React already implements Thenable. We can pass use a Promise and use will track the Promise by converting it into a Thenable (which you see see in the source code).

Using the Thenable is as simple as passing it to use.

const promise = Promise.resolves("Hello world!");

const MyComponent = () => {
	const data = use(promise);

	return <div>{data}</div>;
};

Caching a Promise

In the previous example the Promise is getting created outside of the component. This is important because the Thenable must be created once and reused across renders. If the Thenable is created inside the component, a new Thenable will be created on every render which will cause the component to suspend infinitely.

const MyComponent = () => {
	// recreates the Promise on every render
	const promise = Promise.resolves("Hello world!");
	const data = use(promise);

	return <div>{data}</div>;
};

To cache the Promise created in render, we have a few options. The simplest is the useMemo hook.

const MyComponent = () => {
	const promise = useMemo(() => Promise.resolves("Hello world!"), []);
	const data = use(promise);

	return <div>{data}</div>;
};

For demonstration purposes this works but caching the Promise with useMemo caches the Promise at the component level. If your component unmounts, its key changes, or you have multiple components that need to read the same Promise, you'll need to lift the Promise up to a common ancestor component and pass it down as a prop or via context to prevent redundantly recreating the Promise. This is the typical pattern for lifting up state in React but has a downside of leaking implementation details to the ancestor component. This downside is one of the motivations for Server Components and colocated data fetching.

Moving the cache outside of React

As your use cases get more sophisticated you'll likely want to share your cache outside of React in which case you'll need to move the cache outside of React. This is what a client-side data fetching library like Tanstack Query does with its QueryCache which can be shared across the application. Maybe I'll open that can of worms in a future post. If that topic sounds interesting to you or you have any questions, reach out to me on X (Twitter) and let me know.

Caveats

It's worth mentioning that React discourages this pattern of creating Promises in render in the use function documentation.

Prefer creating Promises in Server Components and passing them to Client Components over creating Promises in Client Components. Promises created in Client Components are recreated on every render. Promises passed from a Server Component to a Client Component are stable across re-renders.

While passing a Promise from the server would resolve much of the issues we have discussed in the post it would be helpful if React provided an opinion on client-side data fetching patterns since it's a prominent use case in the ecosystem. Especially for apps built before Server Components existed.