React Suspense for static site generation

In 18.2 of React there is an addition of Suspense. This allows an interface for handling async data sources and interactions for smoother interface management and also handling concurrent rendering.

For static site generation we can use these new suspense functionality to process data and create static HTML pages. For my usage I want to use React as a templating language. Then we can use data (ex. JSON) to populate our template.

My initial approach was full of issues due to my knowledge of how Suspense works. Suspense works on async data but you need to wrap it in a sync function. The function will be executed until it completes without throwing an error.

Wrap promise

function wrapPromise(promise) {
  // Keep track of the status of the result
  let status = "pending";

  // We keep track of the result of the promise here
  let result;

  // activate the promise to wait for the response
  const suspense = promise.then(
    (r) => {
      status = "ok";
      result = r;
    },
    (e) => {
      status = "error";
      result = e;
    },
  );

  // Returning an object here allows us to access the variables in this function
  return {
    read() {
      if (status == "pending") {
        // we are not complete, throw the promise again
        throw suspense;
      } else if (status == "ok") {
        return result;
      } else {
        throw e;
      }
    },
  };
}
// We wrap our promise in our sync function
const fetchPhotos = wrapPromise(
  fetch("https://api.flickr.com/photos/rockerBOO")
    .then((resp) => resp.json())
    .then((results) => results.photos.photo),
);

Suspense components

File: photos.jsx

function Photos() {
    const photos = fetchPhotos.read();

    return <div>
        {photos.map(photo => {
            return <div>{photo.title}</div>
        }}
    </div>
}

export default Photos

File: app.jsx

function App() {
  return (
    <html>
      <head>
        <meta charset="utf-8" />
        <title>Title</title>
      </head>
      <body>
        <Suspense fallback={<div>loading...</div>}>
          <Photos />
        </Suspense>
      </body>
    </html>
  );
}

export default App;

A few different configurations for how these pieces could work together. We are not making a client version of React here we can make some concessions of how we organize it. If you are doing a hybrid solution, you may need to do other things.

Render to readable stream

In this example, I’m using bun but only small changes to make it work for node.js, Deno, Cloudflare workers or other runtimes.

const stream = await renderToReadableStream(<App />, {
  onError(error) {
    console.error(error);
  },
});

// Wait for all the suspense data sources to complete
await stream.allReady;

// Convert the stream to text (using the Bun functionality)
const html = await Bun.readableStreamToText(stream);

Then you can use the HTML to save to a file or serve from the readable stream you want a dynamic interface.