All About React’s Proposed New use() Hook
First-class support for Promises is coming to React — here’s the proposal on how it’s expected to work
A feature proposal from the React core team is causing some buzz in the React ecosystem, drawing both excitement for its new capabilities, as well as some concerns about how it’s going to be implemented. In this post, we’ll dig into what the new feature looks like, what problem it solves, and what the concerns are being raised.
RFC: First-class support for Promises
The new feature is all about getting “first-class” support for Promises in React, and is described in a RFC (Request for Comments) from a React core contributor.
The document is entitled “first class support for Promises, async/await” and describes new features that better integrates code using Promises into React components.
An RFC does not necessarily mean that a feature will be implemented — anybody write an RFC and open a pull request into the React RFCs repo. In this case though, coming from a core React contributor and with a high level of support, it seems likely that the RFC will be accepted in one form or another and the feature will find its way into a future release of React.
What Problem Does This Solved?
If you’re working with React components that perform any kind of server communication or other asynchronous computation, you currently need to handle that behavior yourself using Promises or some other kind of callback pattern. Here’s a typical code snippet for making a server request and rendering the results inside of a component:
const WidgetList = () => {
const [widgets, getWidgets] = React.useState([])
React.useEffect(() => {
widgetsAPI.get().then((r) => {
setWidgets(r)
})
}, [])
return (<div>
{widgets.map(w => (<p id={w.id}>{w.name}</p>))}
</div>)
}
In this example, our widgetsAPI
call returns a Promise. We then get the results of the call and set the component state via the then
callback. This is a common pattern, and it works reasonably well — but is also fairly verbose and can complicate components as they grow.
At face value, the most straightforward way to support Promises in a component would be to use JavaScript’s existing async/await functionality: simply await
the promise and use the result!
const widgets = await widgetsAPI.get()
In fact, server rendered React components will support async rendering and thus can await
the result of the promise exactly as shown above.
But what about the client side? In the browser, React components cannot be made async for the time being. And as described in the now classic What Color is Your Function, we can’t call await
inside of non-async functions. The RFC discusses this limitation and future plans to support async client components:
We strongly considered supporting not only async Server Components, but async Client Components, too. It’s technically possible, but there are enough pitfalls and caveats involved that, as of now, we aren’t comfortable with the pattern as a general recommendation. The plan is to implement support for async Client Components in the runtime, but log a warning during development. The documentation will also discourage their use.
So no async client components for now — but where does that leave us in the time being? How do we achieve first-class Promise support in the client?
The solution comes in the form of a new sublimely-named hook.
Solution: the New React use() Hook
The solution to the client-side async problem is a new hook, simply named use()
. The hook functions very much like await
in practice, but with some important differences:
const WidgetList = () => {
const widgets = use(widgetsAPI.get())
return (<div>
{widgets.map(w => (<p id={w.id}>{w.name}</p>))}
</div>)
}
Just like await
, use effectively unwraps the value of the Promise returned by our widgetsAPI
. Unlike await
however, the execution of the component is not actually resumed from the same place when the Promise is resolved. Instead, use
throws an exception, just like React.Suspense
to interrupt the rendering. When the promise resolves, the component is re-rendered:
When the promise finally resolves, React will replay the component's render. During this subsequent attempt, the use call will return the fulfilled value of the promise.
The net result of these two approaches should be the same, but there is the potential for differences in behavior. Recall that await
is simply syntactic sugar for calling promise.then(callback)
. So when the promise resolves with await
, execution resumes from the exact same location. But when the promise resolves with use
, part of the component code is re-run and the use
call will return the resulting value.
Now this should not make a difference because, as the RFC states, React components should be idempotent: re-rendering with the same props, state and context should not change the result:
Replaying relies on the property that React components are required to be idempotent — they contain no external side effects during rendering, and return the same output for a given set of inputs (props, state, and context).
In practice however, even though non-idempotent behavior is wrong, it’s certainly possible to build a component in a way that triggers a side effect that will end up getting executed twice. For a simple (and harmless) example, consider you had a console.log
in your widget API function. With the async
implementation, you’d see a single value logged to the console. With the use
implementation, you’ll see two.
Furthermore, the “second execution” of the use
hook introduces another constraint: the API call must cache the results so that they’re available (more or less) immediately when the second call occurs:
The mechanism of waiting for microtasks to flush before suspending only works if the data requests are cached. More precisely, the constraint is: an async function that re-renders without receiving new inputs must resolve within a microtask.
If your API were to not support caching (or not implement it correctly) and instead returned another promise that doesn’t resolve within a microtask (for example, it kicks off a new API call), React would suspend the component render again — presumably resulting in a cycle that continues to make API calls and never completes! To be clear, this scenario would be a developer error, but it could be an easy mistake to make.
Interestingly, unlike other React hooks, the use
hook is exempt from the rules of hooks, meaning that it can be called conditionally, in loops, etc. This quirk is enabled to some extent by the caching requirement: the second render can call use with a “new” promise which accesses the same data and should get a cached result. There is no need to keep track of the order of the use
hook calls, which is why other hooks do follow those rules.
What are Some Concerns with the Approach?
Though it’s exciting to get first-class support for Promises in React, I personally have a number of concerns — and based on the discussion, many of them are shared by others as well. I won’t pretend to have the right answers to all of these questions, but here are some concerns I have that I’m following in the discussions.
The Name
For sure, naming this is the hardest problem in computer science, but the name use()
is very generic and does not convey really convey what the function does and how it works with Promises.
The RFC does address this and points out that use()
is expected to work with other kinds of “unwrapping” in the future, perhaps with contexts or other data types. Additionally, they point out that the use()
name makes it clear that it’s a “React-only” function. These are reasonable explanations, but they do lead into the next concern, which is around the specific behavioral quirks of use()
.
Different Rules for use() vs Other Hooks
As mentioned earlier, use()
is exempt from the normal rules of hooks. This is, at the same time, wonderful and confusing.
It’s wonderful because it removes some constraints on how we use it.
But it’s confusing because — if it doesn’t follow the rules of hooks, is it actually a hook? And that gets us back to the previous question: is use()
really the correct name?
My concern is that the difference in rules between use()
and other hooks will just make it more difficult for new developers to understand the rules of hooks are in the first place, and in fact, harder to understand what hooks even are at their core.
New Constraints on Code Called by use()
While the new use() hook is not subject to the same constraints as other hooks, it does introduce new behavioral constraints around the code that gets used. Namely, the requirements around caching and a lack of side effects of code that produces a Promise passed to use()
.
While the caching behavior is certainly a reasonable requirement, it’s not enforced by any interface or contract. It puts the constraints on the developer of the API function based on implicit knowledge of where it’s being called from.
Instead of putting this burden on the developer, perhaps the cache requirement could be handled internally with an API change and a dependency array like useEffect
:
use(() => myAPI.fetch(id), [id])
Note that the implementation of this approach would likely make the use() hook follow the “normal” hook rules as well (in order to “remember” which call was being used). This is not necessarily a bad thing!
Different Client/Server Component Behaviors
With the introduction of use()
on the client-side and await
on the server, the code, lifecycles and behaviors of client and server components (and the code they touch) begin to diverge in subtle but meaningful ways. This hampers code reuse as well as code understanding.
I respectfully do not buy the argument made in the RFC:
Despite our initial hesitance, there is also an advantage to having different ways of accessing data on the server versus the client: it makes it easier to keep track of which environment they’re working in.
Server Components are meant to feel similar to Client Components, but we don't want them to feel too similar. Each environment has different capabilities, and the distinction needs to be clear to developers as they structure their applications.
If somebody is having trouble keeping track of what environment they’re in, I doubt that the async keyword in the function definition is going to do the trick.
If the intention were truly to keep track of separate environments and validate behavior, there would be many better ways to do so explicitly —e.g. use different namespaces for client & server modules or APIs, introduce a React.serverComponent(…)
wrapper, etc. The explanation above sounds like a post-hoc justification for the diverging APIs.
Promises, Promises…
I think getting first-class support for promises in React is a great idea, but I’m not completely sold on the proposed solution. Fortunately this is still in the proposal phase and there’s lots of good conversation happening about it in the pull request — including discussion around the issues raised in this post and more.
Personally, given some of the concerns with this approach, I think waiting for async client component to get sorted out would not be the worst strategy: instead of inventing a new, tricky, React-only thing, take the time to get it working with async/await
the standard way JS developers would approach the problem. In the meantime, unwrapping values in a callback is not the end of the world.
But I’m willing to believe maybe I’ve got it wrong and that the change is worth making now if it would be a major productivity benefit for React developers. What are your thoughts on the proposed new use()
hook and first-class Promise support?