August 25, 2020
A while back, Dan Abramov mentioned —to my surprise— that it would be relatively easy to have React server side renderer implemented in a different language:
RDS doesn't need the reconciler so it's easy to rewrite by hand. It's about 1kloc: https://t.co/ymAiLBl2Il
— Dan Abramov (@dan_abramov) December 18, 2017
I kept thinking about this regularly, and at some point started wondering how cool it would be to explore using OCaml to implement that server renderer.
But due to laziness lack of time, instead of rewriting it from scratch, I took an existing library that allows to build statically correct HTML (TyXML), and used it to render HTML server side, that can later on be picked up and hydrated by ReasonReact.
The results from the experiment seem promising. The Reason syntax and the JSX extension for TyXML allow the same components to be shared across both server and client environments.
The experiment code is open source, and available in https://github.com/jchavarri/ocaml_webapp. The demo app can be accessed in https://ocaml-webapp.herokuapp.com/. All the pages in this demo app can be rendered by either the server or the client.
This blog post will go through the details on how the experiment went, what troubles were found along the way, and some of the solutions around them.
Photo by Linus Nylund on Unsplash
To summarize, "hydration" is a technique that allows React client-side applications to start faster, by assuming the HTML that React application code would produce was previously rendered by the server and returned as part of the page response.
Based on that assumption, React just needs to attach the event handlers to existing DOM elements, but does not have to create these elements (which is probably the slowest part in the initialization of a React application).
By not having to touch the DOM, the process of starting up a React application avoids a lot of work and can finish in less time.
Hydration is a very nuanced topic and has several performance implications. For most real-world scenarios and applications, hydration will not be the optimal solution. There are other approaches that lead to better results in terms of performance:
However, hydration unlocks a great developer experience, so it is a very hyped topic at the moment. Companies like Gatsby and Vercel are innovating on it very quickly to work around these performance issues while keeping the same great development experience.
If you're curious to know more, I recommend the official documentation and also the post "Rendering on the Web" in the Google dev blog.
Now that we got these performance concerns out of the way, let's go back to the experiment.
React server side rendering (SSR) and hydration is typically implemented using Node, and for good reasons.
There are limitations that are inherent to the "platform gap" between Node and the browser: components rendered in Node can't call methods or APIs available only on the browser (note the same happens in this experiment, between the OCaml native APIs and BuckleScript ones). This gap is not really obvious, and sometimes users of SSR frameworks like Gatsby get confused by errors like window is undefined
. The solution involves doing runtime checks to see if a given global is defined, and from there one can infer that is in one or another environment.
However! React is written in JavaScript, so Node applications that render React components can leverage a lot of previously existing libraries and tools from the extensive React and JavaScript ecosystems.
So, why this attempt to use a completely different language when all this exist already and works in JavaScript? Besides just for pure sake of experimenting 👨🔬 there are some other good reasons.
One reason that makes worth explore rendering components with OCaml native is speed. OCaml binaries can start render some content and return it in an incredibly short time (even less than 2ms!), which makes them very appealing for serverless environments like lambda, which companies like Vercel are migrating to due to their appeal for developers.
OCaml binaries also run pretty fast, and they do well in scenarios where a lot of short-lived small allocations are made (like with parsers or web servers). In general, one can trust that OCaml-generated binaries will run fast.
Another reason to experiment with OCaml to render components is the type system. TyXML is a library that allows to generate valid HTML. The way TyXML guarantees the validity is because it encodes in its implementation the W3C rules for document validity.
For example, if you try to do this:
let t = <ul> <div /> </ul>;
The compiler will complain:
let t = <ul> <div /> </ul>;
˜˜˜˜˜˜˜˜˜˜˜˜˜˜˜˜˜˜
Type 'a = [> `Div ] is not compatible with type
'b = [< `Li(Html_types.li_attrib) ]
The second variant type does not allow tag(s) `Div
One can quickly realize that the only tag allowed inside ul
is li
. I learn about HTML rules from TyXML while I'm coding, which is really amazing.
Note that React has similar invalid HTML detection mechanisms through an internal function validateDOMNesting
, but there are two big differences:
As far as I know, neither TypeScript or Flow, or even ReasonReact, do this kind of static checks to make sure the resulting HTML is valid, although it seems that support for a similar mechanism could be part of ReasonReact at some point.
Components rendered with TyXML can be adapted to look mostly like a ReasonReact component, with a few differences. Here's an example of a Link.re
component from the demo application (source):
open Bridge;
let createElement = (~url, ~txt, ()) => {
<a
className="text-blue-500 hover:text-blue-800"
href=url
onClick={e => {
ReactEvent.Mouse.preventDefault(e);
ReasonReactRouter.push(url);
}}>
{React.string(txt)}
</a>;
};
[@react.component]
let make = (~url, ~txt) => {
createElement(~url, ~txt, ());
};
We will now go through the code of this sample component and see the challenges that the experiment brought up, before we can have a more seamless experience.
createElement
vs make
This is the first and probably more obvious. TyXML offers a JSX ppx2. In this ppx, the elements created from "uppercase" components convert to a call to createElement
. For example:
let t = <Foo bar=2 />
/* will convert to: */
let t = Foo.createElement(~bar=2,());
While in ReasonReact, the JSX ppx makes a slighly different transformation, calling the make
function inside the component module:
let t = <Foo bar=2 />
/* will convert to: */
let t = let t = React.createElement(Foo.make, Foo.makeProps(~bar=2, ()));
So how was this problem fixed? For now, each component exposes both createElement
and make
. Not the most elegant solution I know 😅, but probably this can be simplified in the future by bringing TyXML ppx behavior closer to what ReasonReact is expecting, in terms of naming.
Sometimes the components will need to call functions that are only available in one platform, for example, only in the browser or only on the server. To solve this, there was a small module call bridge
that is available on both sides: server and client.
There are functions that are required to work around ReasonReact and TyXML handling things differently. For example, TyXML allows component children to be a list, but ReasonReact expects them to be a value of type React.element
. So can have a function React.list
that does nothing in TyXML, but calls the appropriate converters in ReasonReact (note there will be a performance cost for this conversion).
So, in TyXML it would be something like3:
module React = {
...
let list = a => a;
};
And in ReasonReact:
module React = {
...
let list = el => el->Array.of_list->React.array;
};
React hooks are also part of this bridge. The functions in React API that allow to create hooks (like useEffect
or useMemo
) only get called after the component has rendered. In the server, these components never really get mounted, we just need to get back the HTML after their render function is called.
So in the server, OCaml native can implement a shim for these functions that is part of the bridge (so the component code does not fail to build) but do nothing when they are called:
let useState: (unit => 'state) => ('state, ('state => 'state) => unit) =
f => (f(), _ => ());
let useEffect0: (unit => option(unit => unit)) => unit = _ => ();
There are more examples in the demo app.
Another interesting challenge involves React event handlers. By default, TyXML does not allow props like onClick
to be passed to elements, as it has been designed originally with HTML attributes in mind. So any components using them will fail to compile.
The solution to this was to add a small update to TyXML ppx, so that it can handle props with React event handlers names. When the ppx finds one of these props, it will make the prop and the value passed with it disappear from the resulting code.
For example, the implementation of the createElement
function in the Link
component above was:
let createElement = (~url, ~txt, ()) => {
<a
className="text-blue-500 hover:text-blue-800"
href=url
onClick={e => {
ReactEvent.Mouse.preventDefault(e);
ReasonReactRouter.push(url);
}}>
{React.string(txt)}
</a>;
};
In ReasonReact, it will remain like shown above. But in TyXML it will be:
let createElement = (~url, ~txt, ()) => {
<a
className="text-blue-500 hover:text-blue-800"
href=url
>
{React.string(txt)}
</a>;
};
This is also cool with regards to shims and platform-specific code. Because this attribute and its value gets eliminated, there is no need to add to shims or care about any code that goes inside it, as the native type checker will never see it.
So, while this prototype proves that it is possible to share some components code between environments and libraries as different as TyXML and ReasonReact, many challenges remain as seen above.
Some future work could involve:
I hope you enjoyed the post, check the demo app in https://github.com/jchavarri/ocaml_webapp, and if you want to share any feedback or have a suggestion, reach out on Twitter.