Re-implementing React Hooks for ServerSide Rendering
preface:
yes, every time I think about Promises in JS, Nero - Promises starts playing in my head.
preface II:
I started writing this in 2020, never finished, but just decided to add a couple touches and publish it anyways, as background info for Shayu Internals
The Beginning
For Shayu I'm starting to get into a habbit of wanting more, which often involves learning a lot more than I expected for 'just' writing yet another static site generator. Shayu is heavily based around React rendering, both in it's content (MDX) and layout templating (JSX). Something I really wanted to support, was a way to do an async operation, and then render the component.
A normal render is fully synchronous however, and thus a Component is a function that synchronously returns an Element, which means we can't just throw a Promise in the chain.
The first attempt
After pitching my idea to joepie91, I got some snippets from something he experimented with in the past:
https://gist.github.com/joepie91/3d6f1df5dacfee258a71405c79fd1ae4
The gist (hah) of it is decoupling the data-fetching Promise from the component, and exporting those as separate functions.
The backend then executes the Promise, and only starts rendering after it has finished, passing the Promise result through a React.Context
.
oh this isn't too difficult, i think :o
-- f0x, July 15th 2020 00:58
development ensues
in reply to the previous message
actually, it is :')-- f0x, July 15th 2020 14:11
Some more headcrunching later though, I had this working in the Shayu architecture, which can be found at commit e741e11b.
The component exports both the promise it needs, and the actual component to render the result in:
1 2 3 4 5 6 7 8 9 10 11 12
module.exports = { promise: function() { return new Promise((resolve) => { setTimeout(() => { resolve({data: "Hello (delayed) world!"}); }, 200); }) }, component: function({data}) { return <span>Component says: {data}</span> } }
This gets intercepted in the require
wrapper (story for another day), which executes promise
and stores the returned Promise, then wraps the result component in a Context that will later Provide the promise result.
1 2 3 4 5 6 7 8 9 10 11 12
if (mod.promise) { renderPromises[path] = mod.promise(); return function(props) { return React.createElement( PromiseContext.Consumer, {}, function(resolvedRenderPromises) { return React.createElement(mod.component, {...props, ...resolvedRenderPromises[path]}); } ); }; }
All the page's Promises are then awaited, and the results are stored in PromiseContext.Provider
, which is wrapped around the MDX component.
So, was that it?
This looked quite good, and I quickly wrote a little component that'd fetch my Mastodon profile then render it. But then I realized:
ah shit, this way you can't pass any of your
props
to the promise-- f0x, July 15th 2020 15:19
Which turned out to be quite a crucial flaw in this setup, because the component's props were completely separate from the promise, which was already run in the require
stage of things.
This also means the promise's result would be shared across all instances of it, so not too useful.
We really need this to behave as much like a 'normal' component as possible (this would also provide the greatest compatibility).
As a refresher, let's look at a normal React component, written with the Hooks API (all I use nowadays):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
function MyComponent(_props) { let [loaded, setLoaded] = React.useState(false); React.useEffect(() => { setTimeout(() => { setLoaded(true); }, 1000); }) if (loaded) { return ( <span>All done :)</span> ); } else { return ( <span>still loading, please wait</span> ); } }
First render it initializes loaded
to 'false', runs the function given to the useEffect hook, and then returns the "still loading" span.
When setLoaded(true)
is called, the state updates and a re-render is triggered. This second render, loaded
is now 'true', and "All done :)" is returned.
The useEffect hook can really do anything, running state setters whenever. This works great for continually updating UIs, but not when we want to send a single render to the client.
Instead, our method needs a way to signal it is fully finished updating state, to then do the final render to static html.
What does things then signals it is done? That's right, a Promise!
We can't directly extend useEffect
, because in normal React the return value is used to register a cleanup function. So, a new hook called useServerEffect
.
It has a single argument that should contain a function to run, which returns a Promise to be resolved when all needed state is set.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
function MyComponent(_props) { let [loaded, setLoaded] = React.useState(false); useServerEffect(() => { return new Promise((resolve) => { setTimeout(() => { setLoaded(true); resolve(); }, 1000); }) }) if (loaded) { return ( <span>All done :)</span> ); } else { return ( <span>still loading, please wait</span> ); } }
We inject useServerEffect
from the Shayu rendering scope, which stores the effect function in a per-page array. Then in the rendering step,
We can even easily make this compatible with client-side React, all the wrapper needs to do is pass the function to React.useEffect and swallow it's returned Promise to not interfere with the expected cleanup function.