This is the second post in a series of reports on my ongoing exploration to bring React’s Server Components to UIx and Clojure JVM. Read the first post with an intro to server components.
Server side rendering
Now that I got basic serialization working for UIx components into Flight format, the next step was to add server side rendering alongside RSC. While server components can be used without server rendering, it doesn’t make much sense and also makes things slower, since a web page needs to download JavaScript first, compile and execute it and only then it makes API request to fetch Flight payload and finally render.
If server can render UI tree into HTML string with embedded Flight payload, then a web page becomes interactive once it’s loaded, in pure HTML sense, and afterwards JS with client components will bring to life dynamic parts of the page.
The illustration above roughly demonstrates the difference in timelines. The second approach skips Flight payload fetching, since it’s already embedded in HTML. However, this means a slight tradeoff — the size of HTML will grow, but not as much as it would if JavaScript code were required to render everything on the client. JavaScript bundle can be loaded sooner, if we add prefetch meta tag into the <head> of the page.
Worth noting that SSR and RSC are independent concepts in React. Both can work on their own, but combining the two brings the best experience.
Data fetching makes things slower
The above illustration is missing an important part — what’s the reason you are doing server side rendering? Most likely, this is because you have some data in your database that you want to embed into HTML. Otherwise, there’s no need for SSR; just generate a bunch of static web pages and put them on a CDN. This holds true independent of your tech stack, whether you are using React, Clojure/Hiccup, Ruby, or PHP.
Whether we are only server rendering or using server components together with SSR, the startup timeline is still mostly sequential. And now with this more realistic setup, JavaScript bundle can be done loading way before entire HTML and Flight payloads are delivered to the client.
Streaming
The ultimate goal is to make UI interactive as soon as possible. So a natural solution here would be to unblock HTML rendering, instead of waiting for the entire thing to complete. Basically, blocking I/O should run concurrently. From client’s perspective, a browser will receive initial HTML with blank (suspended) slots. Call it a "shell".
Later, when I/O unblocks, the server will write generated chunks of HTML into the persistent HTTP connection until the last chunk is written and the connection is closed. Every chunk of HTML comes with a small inline script that replaces the blank slot with contents of the chunk. Try this demo below, it simulates initial HTML load and a stream of subsequent packets.
Notice that while the content is being progressively rendered you can still type into the input field. With this approach we can deliver server rendered HTML shell instantly and immediately let users interact with it without interruptions. Moreover, since v18, React has been doing selective hydration, which is basically a prioritization mechanism that decides what should be hydrated first based on user interactions.
HTML streaming in React
In React, to render HTML concurrently on server, we use Suspense component. I like to think about Suspense as a way to create background threads, where blocking I/O runs until it’s resolved. The fallback property defines a placeholder HTML that will be delivered to the client in the initial payload.
async function Comments({ id }) {
const comments = await db.get(
"SELECT * FROM comments WHERE article_id = ?",
id
);
return <ul>...</ul>;
}
function Article({ id, title, content }) {
return (
<article>
<h1>{title}</h1>
{content}
<Suspense fallback={<Spinner />}>
<Comments id={id} />
</Suspense>
</article>
);
}
When initial HTML is rendered, the fallback is inserted together with a <template> element that serves as a slot for React, where it should insert HTML chunk when it arrives later. Those elements are also wrapped in HTML comments, this is how React understands that something here is pending. Later a chunk of hidden HTML arrives together with a script that swaps the fallback with the content.
<!--$?-->
<template id="T:1"></template>
<span>fallback</span>
<!--/$-->
...
...
<div hidden id="F:1"><span>DATA</span></div>
<script>replace("T:1", "F:1");</script>
Alright, now we can pick between two approaches to SSR: straightforward — single threaded rendering, and a bit more complex, concurrent rendering with streaming. I’m saying threads specifically because this is how UIx implements server-side rendering in Clojure. Every Suspense boundary runs in a core.async thread.
(defui comments [{:keys [id]}]
(let [comments (sql/exec {:select :*
:from :comments
:where [:= :id id]})]
($ :ul ...)))
(defui article [{:keys [id title content]}]
($ :article
($ :h1 title)
content
($ uix/suspense {:fallback ($ spinner)}
($ comments {:id id}))))
Under the hood uix.core/suspense component is doing something similar to this code:
(defui suspense [{:keys [fallback children]}]
(collect (async/thread (serialize children)))
fallback)
While the initial HTML is being rendered, UIx concurrently loops over collected channels in a core.async thread and pushes generated chunks of HTML until the pool of background jobs is drained. async/alts! is somewhat similar to Promise.race in JavaScript.
(async/go-loop [chans collected-channels]
(if (empty? chans)
(close-connection)
(let [[html chan] (async/alts! chans)]
(push-chunk html)
(recur (disj chans chan)))))
Server components streaming
Remember that SSR and RSC are separate concepts. RSC has a similar set of problems, since it’s also a serialization format. Just like HTML for browsers, in RSC it’s Flight format for React. We have two options here as well: serialize UI tree into Flight payload on a single thread and wait for all blocking operations or run things concurrently.
In UIx, when you use server components with SSR enabled, UI tree is unwrapped into internal representation and streamed through HTML and Flight serializers. This way there’s no buffering happening and blocking operations run only once, even though the output is in two formats. If I understand correctly, in React the internal representation is the Flight format itself.
From the client’s point of view, consumption of the Flight stream is a bit simpler. The initial HTML embeds the initial Flight payload, and subsequent chunks are pushed as <script> tags that write into a shared array.
<html>
...initial html...
<script>FLIGHT_DATA = []</script>
<script>FLIGHT_DATA.push(...initial flight...)</script>
...
...
<script>FLIGHT_DATA.push(...flight chunk...)</script>
...
<script>FLIGHT_DATA.push(...flight chunk...)</script>
Once JavaScript is loaded, React picks up the FLIGHT_DATA array and pushes all existing and future chunks into a ReadableStream that pipes through the createFromReadableStream function, which outputs React elements. The output of the function is then rendered as a normal React element.
Routing
In traditional single-page applications, routing is done client-side. Navigating to a route is instant, unless you need to perform auth checks or some other I/O to provide meaningful feedback to users. We can already show some useful content while data is being refreshed from the server in the background.
In classical multi-page websites, navigation is blocked by the server. You press on a link, browser waits until server responds, the content is updated on a screen.
With server components, we are put on a spectrum somewhere between full SPA and classical MPA experiences, where it’s easy to control how much static and dynamic content we want to deliver to users. To preserve SPA-level speed of navigation while most of the content is rendered on the server, RSC-enabled frameworks implement route prefetching.
Different strategies can be applied, but for now, UIx prefetches all routes for links currently visible in the browser’s window. Prefetching is done with low priority, so browsers can prioritize user-initiated requests over background fetches.
When client-side routing detects navigation, it issues a request to the backend to fetch the Flight payload or takes a prefetched response from the local in-memory cache and pipes it into the createFromFetch function, which builds on top of createFromReadableStream.
Depending on the scale of your project, when rendering on server you might want to introduce caching at some point. There are multiple strategies that can be applied; they are not unique to server components or React. We’ll talk about this in future posts.
Server actions stay on server
In the previous post, we introduced server actions; they are basically RPC. When compiled as ClojureScript, the action is turned into a backend API request. On the server, it’s automatically wired into a router.
You can use a server function directly from a client component, but with this update, it’s now possible to pass server actions as arguments to client components from the server. This, I think, creates a stronger separation of concerns because you don’t need to require server code into the client namespace.
(defaction vote [id]
(sql/exec {:update :stories,
:set {:score [:+ :score 1]},
:where [:= :id id]}))
(defui ^:client vote-btn [{:keys [on-click id score]}]
($ :button {:on-click #(on-click id score)}
(str "Vote " score)))
(defui story [{:keys [data]}]
(let [{:keys [time title url]} data]
...
($ vote-btn {:id id :score score :on-click vote})
...))
One thing that I haven’t implemented yet is partial application on the server. In this case, instead of passing both id and score to the client component, it should be possible to bind the vote function to those arguments on the server.
(defui ^:client vote-btn [{:keys [on-click score]}]
($ :button {:on-click on-click}
(str "Vote " score)))
(defui story [{:keys [data]}]
(let [{:keys [time title url]} data]
...
($ vote-btn {:score score :on-click (partial vote id score)})
...))
Client rendered server components
Remember in the previous post I said that client components can’t render server components?
Well, that’s not entirely true. Since everything you pass from the server to client components becomes a server reference (similar to server actions), it should be possible to pass server-rendered components as arguments to client components. This is already implemented in UIx.
(defui ^:client vote-btn [{:keys [on-click children]}]
($ :button {:on-click on-click}
children))
(defui story [{:keys [data]}]
(let [{:keys [time title url]} data]
...
($ vote-btn {:on-click (partial vote id score)}
(str "Vote " score))
...))
Client disappears
You may have noticed that with each of the above additions, our client component becomes smaller and smaller. In fact, we can get rid of it entirely by combining server actions with the HTML <form> element, which was made for performing HTTP requests. This is a future addition that I’ll work on next.
(defui vote-btn [{:keys [score id]}]
($ :form {:action #(vote id score)}
($ :button {:type :submit}
(str "Vote " score))))
(defui story [{:keys [data]}]
(let [{:keys [time title url]} data]
...
($ vote-btn {:score score :id id})
...))
vote-btn is no longer a client component; there’s no client code at all now. There’s still runtime JavaScript that will take care of handling form submission so that the page doesn’t reload, but this will also work in a pure HTML setup, since <form> is natively supported and the :action function is compiled into a URL.
Here’s a demo with latest updates
This concludes the second iteration of my Clojure port of server components to UIx. You can follow the progress in this PR: pitch-io/uix #241, and here’s the updated Hacker News example project. Continue reading the second post, about mutations and data fetching.