Designing Suspenseful Hooks
React Suspense is not just about data fetching and loaders. But, it’s also mostly about data fetching and loaders, and it’s a really good API for that. So it makes sense that data fetching libraries, like @tanstack/react-query
or swr
, provide “suspenseful” versions of their hooks.
Except that the way their suspenseful hooks are implemented (dare I say it) kinda goes against the idea of Suspense.
Consider the following component, which makes two requests with react-query
v5:
// UserProfile.jsx function UserProfile({ userId }) { const { data: user, isPending: isUserPending } = useQuery({ queryKey: ["user", userId], queryFn: () => fetchUser(userId), }); const { data: friends, isPending: isFriendsPending } = useQuery({ queryKey: ["user", userId, "friends"], queryFn: () => fetchFriends(userId), }); if (isUserPending || isFriendsPending) { return <p>Loading...</p>; } return ( <> <h2>{user.name}</h2> <p>Friends: {friends.length}</p> </> ); }
// index.jsx root.render( <> <h1>User Profile</h1> <UserProfile userId="some-id" /> </> );
In a perfect world, there’s a single endpoint that returns all the data we need for our UserProfile
component, including both user data and the amount of friends the user has. But we can’t always dictate how an API works, so it’s fairly common for a single component to make multiple requests.
Currently our UserProfile
component handles both fetching the data and the loading state. The cool thing about Suspense is that it allows us to write components as if the data always exists, and not have to deal with the loading state at the component level. Let’s try using a suspenseful version of the useQuery
hook, useSuspenseQuery
:
// UserProfile.jsx function UserProfile({ userId }) { const user = useSuspenseQuery({ queryKey: ["user", userId], queryFn: () => fetchUser(userId), }); const friends = useSuspenseQuery({ queryKey: ["user", userId, "friends"], queryFn: () => fetchFriends(userId), }); return ( <> <h1>{user.name}</h1> <p>Friends: {friends.length}</p> </> ); }
// index.jsx root.render( <> <h1>User Profile</h1> <Suspense fallback={<p>Loading...</p>}> <UserProfile userId="some-id" /> </Suspense> </> );
This seems great at the first glance, but there’s a problem. Previously, the UserProfile
component initiated two parallel requests:
Now those same two requests are serial. We’ve introduced a waterfall:
And this makes sense. The useSuspenseQuery
hook will suspend rendering if the data is not ready yet. Suspending happens by throwing a promise, so once the first query suspends rendering, the second query doesn’t even have a chance to run until rendering is unsuspended.
How does the component get suspended?
Although it’s not currently documented, this is basically how Suspense works: throwing a Promise
object from the component during render causes React to scrap the entire component subtree up to the nearest Suspense boundary (similar to how throwing an error from render is handled by the nearest error boundary).
React will then render a fallback and continue working on other subtrees. When the thrown promise resolves, React renders the suspended subtree again. In the case of react-query
, the fact that the promise is resolved means that the data is cached, so this time the hook doesn’t throw and instead returns the data from the cache.
The solution suggested by react-query
is to use the useSuspenseQueries
hook. Instead of using useSuspenseQuery
twice, you write:
// UserProfile.jsx const [user, friends] = useSuspenseQueries({ queries: [ { queryKey: ["user", userId], queryFn: () => fetchUser(userId), }, { queryKey: ["user", userId, "friends"], queryFn: () => fetchFriends(userId), }, ], });
This prevents the waterfall from happening, but in every other way it is a downgrade. It doesn’t look as nice, but more importantly, it’s less composable. We used to be able to extract our queries into reusable custom hooks:
// api.js export function useUser(userId) { return useSuspenseQuery({ queryKey: ["user", userId], queryFn: () => fetchUser(userId), }); } export function useUserFriends(userId) { return useSuspenseQuery({ queryKey: ["user", userId, "friends"], queryFn: () => fetchFriends(userId), }); }
The developer importing these hooks didn’t have to worry about react-query
at all, and could compose them however they wanted. Now, our best option is to provide reusable query configs and to ask the developer to use react-query
hooks directly, which is not nearly as nice.
The root cause of our problems is that the useSuspenseQuery
hook is suspending too early. We haven’t even had a chance to use the data in any meaningful way, but our component is already suspended!
Now imagine that instead of throwing and suspending, the hook we use to fetch the data returns a promise. We then use a special helper function (let’s call it use
) that either returns the value of the promise synchronously, or throws the promise and suspends rendering if the promise has yet to be fulfilled. (You could say that the use
function unpacks a value inside the promise, and throws the promise if it cannot be unpacked yet.)
Then our code might look something like this:
function use(promise) { /* We'll implement it later */ } // UserProfile.jsx function UserProfile({ userId }) { const user = useUser(); const friends = useFriends(); return ( <> {/* unpack 👇 user before accessing */} <h1>{use(user).name}</h1> {/* unpack freinds 👇 before accessing */} <p>Friends: {use(friends).length}</p> </> ); }
// index.jsx root.render( <> <h1>User Profile</h1> <Suspense fallback={<p>Loading...</p>}> <UserProfile userId="some-id" /> </Suspense> </> );
Let’s take a line-by-line look at how React renders this component.
First, two requests are made in parallel:
const user = useUser(); const friends = useFriends();
The hooks don’t suspend, and they don’t actually return the data: instead, they initiate requests, and return promises associated with those requests.
Then, we try to unpack the first promise:
<h1>{use(user).name}</h1>
If the user data isn’t already cached (which it probably isn’t), the call to use(user)
will throw a promise and suspend the component.
Once this promise is fulfilled, React will try to render UserProfile
again. This time, no new requests will be initiated: the user data is already cached by our library, so there’s no need for a new request; the friends data may also already be cached at this point, but if it is not, the request should still be deduplicated based on the query key.
During this second render, use(user)
doesn’t throw and instead returns the cached data. If the friends data is also ready, then use(friends)
won’t throw either, and we’re done. If not, use(friends)
will throw a promise — and we’ll go through the whole cycle again.
The hypothetical use
function is not that difficult to implement in practice:
function use(promise) { if (promise.status === "fulfilled") { return promise.value; } if (promise.status === "rejected") { throw promise.reason; } if (promise.status === "pending") { throw promise; } promise.status = "pending"; promise.then( (value) => { promise.status = "fulfilled"; promise.value = value; }, (reason) => { promise.status = "rejected"; promise.reason = reason; } ); throw promise; }
An important thing to note here is that we mutate the promise object to “smuggle” the value it was resolved with — or the error it was rejected with — onto the promise itself. That way, the next time use
is called with the same promise, we can return the value synchronously. This is important because the implementation of use
can’t be async, otherwise we won’t be able to use it during rendering.
Another, perhaps cleaner way to achieve the same thing is to have a global WeakMap that maps promises to their values:
const promiseResults = new WeakMap(); function use(promise) { const result = promiseResults.get(promise); if (result) { if (result.status === "fulfilled") { return result.value; } if (result.status === "rejected") { throw result.reason; } throw promise; } promiseResults.set(promise, { status: "pending" }); promise.then( (value) => { promiseResults.set(promise, { status: "fulfilled", value }); }, (reason) => { promiseResults.set(promise, { status: "rejected", reason }); } ); throw promise; }
(Without smuggling values onto the promise object, this implementation feels less hacky to me.)
Whichever implementation we choose, you may have noticed a potential pitfall. In order for the use
function to work properly, we must pass the same instance of the promise for each fetched value. This means that the data fetching library must be very careful not to accidentally create new promises on subsequent renders.
Clearly, this implementation will not work:
// This will not work! function useQuery({ queryKey, queryFn }) { return queryFn(); }
It creates a new promise on each render, so the use
function will always throw. Instead, we need to cache our promises:
const cache = new Map(); function useQuery({ queryKey, queryFn }) { const key = queryKey.join("."); const entry = cache.get(key); if (!entry) { entry = queryFn(); cache.set(key, entry); } return entry; }
We must also be careful to avoid calling an async function on every render:
async function fetchOrGetCached({ queryKey, queryFn }) { /* ... */ } function useQuery(options) { return fetchOrGetCached(options); }
The caveat here is that fetchOrGetCached
creates a new promise with each call, even if it is resolved immediately. So, again, the use
function will always get a brand new promise and throw.
Why even make useQuery
a hook at this point?
Good question! Our implementation of useQuery
doesn’t call any React hooks, so it doesn’t need to be a hook itself. In real life, however, it will probably create a subscription (e.g. with useSyncExternalStore
) to update the component when the stale data is refetched, or when the cache is invalidated.
Still, it’s a good idea to also provide a non-hook version of useQuery
, so that requests can be made not only by React components, but also by regular JavaScript code.
With a little care around our promises, we get the desired experience: requests run in parallel, we only suspend when the data is actually read, and developers can compose as many data-fetching hooks in their components as they like.
It even unlocks new patterns, such as passing promises as props and suspending further down the tree:
// UserProfile.jsx function UserProfile({ userId }) { const user = useQuery({ queryKey: ["user", userId], queryFn: () => fetchUser(userId), }); const friends = useQuery({ queryKey: ["user", userId, "friends"], queryFn: () => fetchFriends(userId), }); return ( <> <h1>{use(user).name}</h1> <Suspense fallback={<p>Loading friends...</p>}> <FriendsList friends={friends} /> </Suspense> </> ); }
// FriendsList.jsx function FriendsList({ friends }) { return use(friends).map((friend) => { /* ... */ }); }
// index.jsx root.render( <> <h1>User Profile</h1> <Suspense fallback={<p>Loading profile...</p>}> <UserProfile userId="some-id" /> </Suspense> </> );
Because UserProfile
doesn’t try to unpack the friends data, it will never suspend on it. Instead, it’s the FriendsList
component that will suspend if the friends data is not ready. If the friends data takes longer to load than the user data, we’ll see the user profile partially loaded with the friends list suspended and the “Loading friends...” loader visible.
This pattern comes in handy when we want to start fetching data as soon as possible to avoid waterfalls, and to gradually reveal parts of the UI as the data trickles in.
So! If there’s a single takeaway from this lengthy article, it’s this one:
Your library hooks should not suspend. Just return a promise, and let the developer decide when to suspend.
And the best news: React 19 will include an out-of-the-box implementation of the use
hook! Until then, you can ship one of the implementations from this article along with your library, and later provide a migration guide for React 19 users. Or, why not publish a polyfill for the use
hook right now? It’s a useful pattern that can benefit React 18 users as well.
As an aside, it’s no wonder that developers are confused about how best to use Suspense in their libraries, since there’s no real guidance from the React team. It’s been two years since Suspense was released in React 18, and there’s still no documentation for library authors.
Hopefully, with React 19, we’ll get proper documentation, leading to wider (and more idiomatic) adoption of Suspense in both libraries and applications.