Normally when I'm returning a component (I'm using nextjs 13) that relies on fetched data, I conditionally render elements to ensure values are available:
TableComponent:
export const Table = ({ ...props }) => {
const [tableEvents, setTableEvents] = useState(null);
useEffect(() => {
fetchAndSetTableEvents();
}, []);
async function fetchAndSetTableEvents() {
const fetchedEvents = await fetch(* url and params here *)
if (fetchedEvents && fetchedEvents) {
setTableEvents(fetchedEvents);
} else {
setTableEvents(null);
}
}
return (
<React.Fragment>
<div>
{tableEvents ? tableEvents[0].title : null}
</div>
</React.Fragment>
)
};
If I try and load TableComponent from a parent component using Suspense, it loads but doesn't show the fallback before it's loaded:
<Suspense fallback={<div>Loading Message...</div>}>
<TableComponent />
</Suspense>
However, if I remove the conditional rendering in TableComponent and just specify the variable, the fallback shows correctly while it's attempting to load the component:
return (
<React.Fragment>
<div>
{tableEvents[0].title}
</div>
</React.Fragment>
)
But it ultimately fails to load the component as tableEvents is initially null and will vary on each fetch so it cannot have a predictable key.
The React docs for Suspense just show a simple example like this.
Conditionally returning the render also returns the component ok but fails to show the suspense fallback
if (tableEvents) {
return (
<React.Fragment>
<div>
{tableEvents[0].event_title}
</div>
</React.Fragment>
)
}
How do I fetch and return values in a component, that may or may not exist, that satisfy the criteria for a Suspense fallback to show when loading. I'm assuming it relies on a Promise in a way that I'm blocking but can't find a way around.
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.
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.
Except that now we have 2 issues:
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).
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.
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>
}
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 collectionSuspense
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.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.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