Delayed React
Preface: I hope this article is understandable to everyone, but some basic knowledge of modern JavaScript, React (with React Hooks), and asynchronous operations (with Promises) might be required.
Introduction
I started writing yet another static site generator more than 2 years ago now. I discovered MDX which seemed like the ideal format to write in for me, as it combines the simplicity of Markdown with inline React components for more expressive HTML. The existing (static) site generators that used it didn't seem great to me though, and site generators are an amazing wheel to reinvent for the umptillionth time.. and thus I started writing Shayu.
Somewhere before starting on all that I fully switched to writing all my React with the Hooks syntax, and for simplicity that's the only type of React components I'll mention in this article. I hope to write a simple React (Hooks) primer somewhere in the close future, which I'll link here and probably distribute wherever you found this article as well. React's own documentation also describes them reasonably well.
A basic static site generator
Scaffolding out a basic ssg that supported MDX rendering wasn't too hard;
- list all the files in the input directory
- map them to output paths (pages/index.html -> build/index.html, pages/test.mdx -> build/test/index.html)
- render the file contents inside an MDX component
- which itself is inside a layout/template that's just another React component
- which gets rendered by
ReactDOMServer.renderToStaticMarkup()
- write the resulting render HTML to the output path
- have a separate browserify/icssify pipeline that uses PostCSS plugins to process the stylesheets
- other assets like images and fonts are simply copied over to the build directory The build directory can then be copied to a generic webserver, or pushed to Pixie Pages (blogpost pending).
Parts of this tech stack are opinionated and arbitrary, it's modelled after the various libraries I already use in other projects, and am quite happy with.
Rather quickly though I wanted to fly even closer to the sun: how could a component used by the MDX page make use of asynchronous data? A page or template could then include a component that fetches a Mastodon profile's info, or rely on async filesystem operations to process data before showing it.
React and Asynchronicity
React renders a tree of 'components', where all of them can return more components or plain HTML, or a mix. It starts at the root component passed to the render call, and follows it's returns until everything is rendered once, and then updates the page's HTML to match the outputs. On client-side React components are then strategically re-rendered as their inputs change, to result in updated HTML for the user.
When using React as a server-side renderer it's simpler though: the tree is rendered once, and the HTML is returned. It's a great way to generate HTML for users, as React is much more flexible and expressive than trivial (string-replacement) template engines. If you combine it with React on the client you can also do some awesome 'Hydration', where the client's React takes the server's render as a starting point for further updates. It's thus also perfect for a static site generator, which takes the set of inputs, renders it, then stores the resulting HTML as a file which can then be served by any static webserver.
A React component is a synchronous function: your component gets called with a set of arguments (props) and is expected to return HTML, or more React components (which you supply the props to) to continue rendering. This works great if you immediately know what you want to render as a result, but in more complex components you might have to wait on operations that take a while to complete, like filesystem or network calls.
In client-side React that's not an issue: on first render you can start your asynchronous operation, and immediately return a temporary render result, like a
loading spinner or just null
. When your async operation returns, you can update the component's state
, which tells React it has to re-render your component with
the updated info, where you can then return your result as HTML.
It's a lot trickier with the server-sides single render methods though: you can start a Promise and when it returns set state, but there will never be another render with that new data, because the only render pass has already finished (resulting in your fun spinner component, and nothing else). So that's the problem statement I started working on with Shayu.
I wrote about my approach a while back, but didn't really finish it and wasn't too happy with the article and it's proposed solution. It's options boiled down to the following:
Separate component and fetcher
Keep the component and it's asynchronous data fetching separate, execute the fetcher then render the component once all the promises have a result. This is the simplest option, but I really didn't like the ergonomics, because suddenly your import is no longer a standard React component, but a custom Object/Array that combines it with a fetcher function. It was also impossible to use the props, given to the component when used on the page, in the fetching function, so you're basically limited to unique components and can't reuse them with different options.
useServerEffect
Emulating the way a client-side component would deal with asynchronous fetching.
An initial render pass is done (single renderToStaticMarkup
), which captures the promises passed to useServerEffect calls (API modelled after React.useEffect()
). Once the promises are finished, execute another render pass. This relies on storing React's State for the component (which is where the Promise result is stored + retreived from) out of band, because as far as React is concerned these renders are entirely separate and start with a blank slate.
Matching that state to the proper component on second render turned out to be rather tricky, but maybe it could've worked, resulting in a barebones double-render setup that can deal with basic asynchronous data on the server (or static site generator) side.
It was interesting to dive into React's internals and reverse-engineer the useState
mechanism (something that really deserves it's own post), but I just couldn't get it to work reliably.
Other, newer projects grabbed my attention, and for a while I kind of gave up. My site used an <Img/>
component that asynchronously fetched the image's height+width either from disk or network, to provide a properly sized placeholder, but I didn't use asynchronous components for more than that, because they were too fragile.
New revelations: React 18
Then finally something happened: React v18.0 released and with it came ‘Suspense in Data Frameworks’. The announcement is rather vague, but this turned out to really be the missing piece for Shayu.
In React 18, you can start using Suspense for data fetching in opinionated frameworks like Relay, Next.js, Hydrogen, or Remix. Ad hoc data fetching with Suspense is technically possible, but still not recommended as a general strategy.
While promising, implementation was definitely left as an exercise to the reader, stating
In the future, we may expose additional primitives that could make it easier to access your data with Suspense, perhaps without the use of an opinionated framework.
But I want it now! no, yesterday!
The Suspense is killing me
Digging through React documentation not much was to be found, except “New Suspense SSR Architecture in React 18 #37”. I still haven't read the extensive explanation, but it had a very useful codesandbox demo. It took some time to digest, but in the end the implementation is rather simple and oh so elegant.
Before implementing it in Shayu I wrote a stand-alone demo that was simpler than the CodeSandbox source.
Here are it's parts:
Component calls useData
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
function MyComponent() { let data = useData(() => { return new Promise((res) => { setTimeout(() => { console.log("finished"); res("My Promised return."); }, 1000); }) }); return ( <span> I'm an asynchronous component, and I waited for {data} </span> ); }
I'm an asynchronous component, and I waited for My Promised return.
It passes a function that'll return the desired Promise, which in turn eventually resolves with the data the component is waiting on
useData function
The useData
function is pretty wild, accessing a Context
that Shayu wraps around the entire render tree.
1 2 3
function useData(promise) { return React.useContext(ShayuContext).get(React.useId(), promise); }
It uses React.useId()
(also new in v18.0) as a key which will remain consistent across renders of this component, and passes the promise returning function along to
The Context
The Context element sits at the very base of the tree, allowing any downstream component to access it for keeping track of Promises:
1 2 3 4 5 6 7 8 9
function TopLevel() { return ( <ShayuContext.Provider value={contextData}> <div> <MyComponent/> </div> </ShayuContext.Provider> ); }
contextData is where the real logic is:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
let cache = new Map(); let contextData = { get(id, promise) { if (cache.has(id)) { console.log("retreiving", id); let entry = cache.get(id); if (entry.done) { return entry.result; } else { // unknown edge-case console.log("promise accessed again but not finished yet?") throw entry.promise; } } else { console.log("starting", id) let entry = { promise: promise() }; entry.promise.then((res) => { entry.result = res; entry.done = true; }); cache.set(id, entry); throw entry.promise; } } };
If the key has not been accessed before, it starts the Promise function provided, storing it in the cache with the provided key. Most importantly it then throws
the promise, which signals to React to Suspend this part of the render. Once the Promise resolves, React re-renders the originating component, which accesses the cache again, this time returning the result.
Implementing this in Shayu cleaned up the code a lot, as there's no need to keep track of React State ourselves, nor to do a double render. The resulting merge was great: commit
Errors?
A last thing I initially forgot was handling rejected promises. React still ends the Suspense and the component would try to fetch the data again with the same key, but the promise wouldn't be marked as done yet, so not good. It resulted in the "promise accessed again but not finished yet?" looping over and over again.
Shayu now catches any remaining errors on the Promise and will log a warning about it, but components are really expected to do their own error handling on the Promise, because they will still continue to execute albeit without the expected return value.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
function MyAdventure() { let data = useData(() => { return new Promise((res) => { setTimeout(() => { res("My Promised return."); }, 1000); throw new Error(); }).catch((e) => { return "nothing!"; }) }); return ( <span> I'm an asynchronous component, and I waited for {data} </span> ); }
I'm an asynchronous component, and I waited for nothing!
Future plans
With Suspense asynchronous React works super nicely in Shayu, although it still needs some more testing. The code is much more elegant, and way less fragile. Suspending components can also return more suspending components as needed, instead of being limited to 2 single renders.
A new frontier for Shayu will be investigating to make the static site generator less 'static', instead re-running (some special) components on either the server of client, to provide even more options for dynamic content.