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:

    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

    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