Search code examples
javascriptreactjsnext.jsreact-suspense

Conditionally returning a React component to satisfy a Suspense fallback


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>
    )
}

Question

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.


Solution

  • 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