-
Notifications
You must be signed in to change notification settings - Fork 7
New use() docs for Promise subclasses
#3
Overview
tl;dr: There's a new docs PR for how to create promises for use(): reactjs/react.dev#8125
When implementing suspense enabled data fetching, there's a few subtle details that haven't been documented yet around when and how to create the promises you suspend on. We're fixing that with a docs PR here: reactjs/react.dev#8125.
In this post, I want to provide some more context and open it up to any questions that might not be clear in the docs. Notes that I'm not covering how to cache the promises, but how and when to create the promise that you pass to use() in your data fetching layer.
Conditional suspending not recommended
First, you shouldn't conditionally call use based on whether you have the data or not:
if (data) { return data } return use(promise);
There are a number of reasons for this (taken from @sebmarkbage in react/react#34030):
- (Future optimization):
use()keeps track of the order it was last called similar to other hooks. When a Promise is resolved without any updates, we can continue where we left off by rerendering the component and using the resolved value in eachuse()slot. If the second render picks a different path, even though no state or props have changed, then theuse()will line up in the wrong order and you can get data corruption by lateruse()observing the value of previoususe(). - You can avoid this data corruption by never resolving the Promise and instead causing an update to a parent that flushes down to rerender the component. However, this doesn't work with SSR anyway since in SSR you need to resolve the Promise in that environment.
- If you call
setStateoruseSyncExternalStoreto cause a component to take a different path to unblock a Promise then you'll also cause other optimizations to deopt. For example, it'll force a dehydrated Suspense boundary to force hydration which can cause it to switch to client rendering or flip back to a loading state after having already shown the content. That's because React can tell that if you resolved a Promise then you're still in "hydration mode" given the original data. If you do a setState then React may need to throw away the SSR:ed state. At best you'll do multiple unnecessary rerender of the whole tree from the parent to the suspended point instead of using resuming where it left off. - Additionally, React treats resolving a Suspense boundary differently than an update. For example, resolving a Suspense boundary can be throttled where an a pure setState needs to be flushed as soon as possible. It also affect whether or not a View Transition can be applied to the reveal or not depending on if it's a Transition or sync update.
- Interaction Tracing and the Performance Timeline needs to be able to differentiate between when a new Transition is moving to a new screen vs when there's more data that loads on the current screen. If you unsuspend by calling setState in a Transition instead of just resolving the promise, then that's considered a new navigation instead of data loading in the current navigation.
- If you're not calling
use(promise)after it resolves, then React DevTools can't track that as a component that might suspend. Because it can't differentiate between asetStatethat was a navigation to a different page and the new page isn't suspended by this value, vs. asetStatethat was used only to unblock this value.
In the future we plan to warn for this to help catch these issues.
Note: Conditional use(promise) is still fine in user code
Note that this doesn't mean that you "can't conditionally call use(promise)" in user code.
For example, this is fine:
if (cond) { return null } const data = use(dataPromise); return <Child data={data} />
The difference here is that in user code, the cond here would be a state update, which is fine. The issue is specifically related to conditionally reading from a cache.
Subclassing Promises
Because of the above, you will need a way to use(promise) in a way that makes the promise data synchronously available if the promise has already resolved (such as for preloading), or the data is already available (such as in a cache).
Common use cases for this are:
- Preloading (either on the client, or from the server)
- Updates to the current page after the promise resolves
- Back navigations (data is still cached, needs to immediately reveal)
To support these cases, React already instruments fields on the promise so use() knows the data is already resolved. So if you continue to use the same promise, it just works.
However, anytime you create a promise as a wrapper for some kind of cached data that is already available, that new promise will not have a chance for React to instrument it. Without this instrumentation, use() will Suspend to at least the next tick so the thenable can instrument the values.
In the past, we tried to just wait a tick before showing the fallback to see if the promise data is available, but this ends up delaying the fallback in the cases it's not available and has a bunch of other tricky issues.
Instead, we recommend subclassing the promise.
There are a few ways you can do this, by actually subclassing it (which feels more platformy):
class PromiseWithStatus extends Promise { // if data is available, set these fields on create status = "pending"; value = null; reason = null; // ... }
Or by setting the fields:
const promise = Promise.resolve(value);
promise.status = "fulfilled";
promise.value = value;
Or you could create a thenable-like object:
const thenable = { status: 'fulfilled', value: value, then: () => {...} }
The later is slightly faster, but I don't really want to get in debates about whether it's good or not - you can choose your level of strict spec adherence.
Docs
This is all just context for the docs @eps1lon @Ephem and @sebmarkbage have been iterating on in reactjs/react.dev#8125.
If you have a chance, please check out the new docs and feel free to ask questions here or on the PR.
All reactions
-
❤️ 6 -
👀 2