TL;DR
For Suspense
to be triggered, one of the children must throw
a Promise
. This feature is more aimed at library developers but you could still try implementing something for yourself.
Pseudocode
The basic idea is pretty simple, here's the pseudo-code
function ComponentWithLoad() {
const promise = fetch('/url') // create a promise
if (promise.pending) { // as long as it's not resolved
throw promise // throw the promise
}
// otherwise, promise is resolved, it's a normal component
return (
<p>{promise.data}</p>
)
}
When a Suspense
boundary is thrown a Promise
it will await it, and re-render the component when the promise resolves. That's all.
Problem
Except that now we have 2 issues:
- we need to be able to get the content of our promise without async/await since that's not allowed in react outside of "Framework Land"
- upon re-render,
fetch
will actually create a new promise, which will be thrown again, and we'll be looping forever...
The solution to both of these issues is to find a way to store the promise outside of the Suspense
boundary (and in most likelihood, outside of react entirely).
Solution
Obtain promise status without async
First, let's write a wrapper around any promise that will allow us to get either its status (pending, resolved, rejected) or its resolved data.
const promises = new WeakMap()
function wrapPromise(promise) {
const meta = promises.get(promise) || {}
// for any new promise
if (!meta.status) {
meta.status = 'pending' // set it as pending
promise.then((data) => { // when resolved, store the data
meta.status = 'resolved'
meta.data = data
})
promise.catch((error) => { // when rejected store the error
meta.status = 'rejected'
meta.error = error
})
promises.set(promise, meta)
}
if (meta.status === 'pending') { // if still pending, throw promise to Suspense
throw promise
}
if (meta.status === 'error') { // if error, throw error to ErrorBoundary
throw new Error(meta.error)
}
return meta.data // otherwise, return resolved data
}
With this function called on every render, we'll be able to get the promise's data without any async
. It's then React Suspense
's job to re-render when needed. That what it does.
Maintain a constant reference to Promise
Then we only need to store our promise outside of the Suspense
boundary. The most simple example of this would be to declare it in the parent, but the ideal solution (to avoid creating a new promise when the parent itself re-renders) would be to store it outside of react itself.
export default function App() {
// create a promise *outside* of the Suspense boundary
const promise = fetch('/url').then(r => r.json())
// Suspense will try to render its children, if rendering throws a promise, it'll try again when that promise resolves
return (
<Suspense fallback={<div>Loading...</div>}>
{/* we pass the promise to our suspended component so it's always the same `Promise` every time it re-renders */}
<ComponentWithLoad promise={promise} />
</Suspense>
)
}
function ComponentWithLoad({promise}) {
// using the wrapper we declared above, it will
// - throw a Promise if it's still pending
// - return synchronously the result of our promise otherwise
const data = wrapPromise(promise)
// we now have access to our fetched data without ever using `async`
return <p>{data}</p>
}
Some more details
WeakMap
is pretty ideal to map between a promise
and some metadata about this promise (status, returned data, ...) because as soon as the promise
itself is not referenced anywhere, the metadata is made available for garbage collection
- While a component is "under suspense" (meaning any component in the render tree from it to the next
Suspense
boundary throws a promise), it will be unmounted by react after each "attempt" at rendering. This means that you cannot use a useState
or a useRef
to hold the promise or its status.
- unless you are writing an opinionated library (like tanstack-query for example), it's almost impossible to have a generally valid way of storing promises. It's entirely dependant on your application's behavior. It might be as simple as having a
Map
between endpoints and the Promise
fetching that endpoint, and only grows in complexity from there with refetches, cache-control, headers, request params... This is why my example only creates a simple promise once.
Answer to the question
When using Suspense
, none of the tree inside of the Suspense node will be rendered while any of it still throws a Promise. If you need to render something in the meantime, that's what the fallback
prop is for.
It does require us to change the way we think about the segmentation of our components
- if you want your fallback to share some of the structure / data / css with the suspended component
- if you want to avoid a waterfall of loading components preventing a big render tree from displaying anything at all