Towards React Server Components in Clojure, Part 1 - Roman Liutikov, Software Engineer

11 min read Original article ↗

This is the first post in a series on my ongoing journey to bring React's Server Components to UIx and Clojure JVM.

Content delivery strategies

In 2013, React started as a client rendering library. From day one, it also provided serialization into an HTML string, making it possible to use it for static site generation or delivering initial content to clients faster while JavaScript was being loaded.

The full SPA approach is perhaps the easiest way to create a rich interactive web application. But React's component model and the ecosystem of abstractions built on top also proved compelling for building typical websites in React as well. Of course, embedding mostly static content into a JavaScript bundle seemed wasteful, and that's where server rendering comes into play.

With server rendering, there are two approaches that I've frequently encountered. An easy one, where the entire app is still shipped in a JS bundle, and the initial HTML is rendered by the server for the requested route. A slight variation of this approach is to server render a shell (mostly static content) and let the client fill in the rest of the page.

Then there's a similar but more granular approach, where the server becomes the main driver, instead of the client. This suits well the type of websites where most of the page consists of static HTML, with multiple dynamic islands. And by static HTML, I mean static from the client's perspective; it can still be rendered via React on the server.

When such a page is loaded, the empty islands (optionally rendered with static placeholders) are then hydrated on the client from JavaScript.

This approach is quite traditional, almost like sprinkling bits of jQuery here and there on my old WordPress websites. With the exception that server rendering is done in a different language, I can't freely move pieces of content between static HTML and dynamic JavaScript when I decide something needs to be more or less dynamic.

The downside of the dynamic islands approach is that you have to keep client and server code in sync and manually instruct React where to render client-only content.

Server Components

In 2020, the React team delivered the first demo of server components. The idea behind React taking over client and server was to blur the line between both and give developers the tools for building websites with just the right amount of static and dynamic content. So that approaches like manual dynamic islands become an unnecessary maintenance burden. Not only does content delivery become transparent, but server components also take care of gluing together client interactions and server-side code execution.

If you are using a Server Components-enabled framework, this is all you need to render an HTML page with an article and an interactive button element that increments a likes counter on the backend.

async function likeArticle(id) {
  "use server";
  await db.exec("UPDATE articles SET likes = likes + 1 WHERE id = ?", id);
}

function LikeButton({ id }) {
  "use client";
  return <button onClick={() => likeArticle(id)}>Like</button>;
}

async function Page({ id }) {
  const { title, content } = await db.get(
    "SELECT title, content FROM articles WHERE id = ?",
    id
  );
  return (
    <article>
      <h1>{title}</h1>
      <p>{content}</p>
      <LikeButton id={id} />
    </article>
  );
}

In such frameworks, by default, all code runs on the server. The "use client" directive turns React components into client-side components that are bundled into a JavaScript file at build time and served together with generated HTML page. So essentially, the client directive gives you control over how much content you want to render on the server versus the client.

The "use server" directive turns backend code into server functions. Server functions can be referenced from client components. At build time, a reference to a server function is transformed into a backend API call, so obviously, no database usage occurs on the client, and none of the backend code is included.

It's worth noting that it's not possible (or rather, not allowed) to interleave client and server components in the UI hierarchy. I don't think it's a fundamental limitation of server components, but at least that's what React docs will tell you: server components on the outside, client components at the leaves of the UI tree. And that makes sense, since it aligns with a typical approach of serving web apps.

However, it is possible to pass a server component to a client component as a prop. In this case, the server component will be supplied to the client component as a reference in the Flight payload.

<ServerComponentA>
  <ClientComponent>
    <ServerComponentB />
  </ClientComponent>
</ServerComponentA>

You can see how with server components React becomes a server-first framework rather than a client-side rendering library. But really it's more of a tool for building different types of websites, instead of being mostly focused on client-side development with a less appealing server story.

Server Components outside of Next.js

Server Components are not part of the React library itself; you can't just install and use them from NPM. Running a project with server components requires a build system that is able to draw a line between client and server code using server and client directives, a server abstraction to consume and execute server actions and serve rendered payloads, and specialized routing that works across the client and server.

Given such a list of requirements, it's no wonder that all Server Components implementations are provided as part of web frameworks.

It's my understanding that Server Components were built by React team together with Vercel, for their Next.js framework, and currently they are the de facto owners of the “golden standard”. There's no official specification for Server Components available; all bleeding-edge features are published in React's Canary builds.

At the time of writing, I'm aware of only a few open implementations of Server Components: OpenNext, Waku, RedwoodJS, and react-server. Also, a few months ago, Parcel bundler introduced support for server components; this is probably the first complete and minimal implementation I'm aware of.

Digging for clues

There's some information available on the web about the internals of Server Components. “React Server Components, without a framework?” is a good guide towards building a very minimal implementation for learning purposes. “Functional HTML” helps to build a mental model. There's also some workshop resources and a couple of public talks: “Meet React Flight and Become a RSC Expert” and “React Server Components from Scratch”

Combined with the above info, the most effective approach for me was to dump the articles together with react-server-dom-esm package's source code into ChatGPT and ask it to write a detailed specification on Server Components. This was a huge time saver, especially because ChatGPT is able to search the internet and carve out relevant bits of information, so I could focus on more important stuff.

React Flight format

At their core, Server Components are a wire format. When rendered on the server, React components are serialized, sent to the client, and deserialized into an in-memory structure that gets rendered into the browser's DOM. For the initial page load, a server may choose to transform the Flight payload into an HTML string. If you are building a mostly static website, a form submission, for example, can ask the server to respond with a Flight payload, which React will use on the client to swap a specific part of the content in the page.

All of this sounds a lot like HTML itself. Why not simply send chunks of HTML? While React is an abstract renderer, with concrete implementations for browser, mobile, desktop, terminal, and other environments, React Flight is a format for communicating these abstract component structures.

The Flight format is a row-based JSON format that supports references, serialization of React elements, data types such as Date, Set, Map, and Error, and async types such as Promise and ReadableStream. It is row-based because the format supports async values and thus streaming.

Here's an example of a server-rendered payload that uses the Button client component:

<div title="hello">
  <Button title="btn">press me</Button>
</div>
1:I["/chunk.js","Button",false]
0:["$","div",null,{"title":"hello","children":[["$","$L1",null,{:title "btn", :children "press me"}]]}]

Each row gets an id prefix, followed by a value serialized into JSON. A value prefixed with I stands for import and points to a JavaScript file containing client code. In this case, it's the chunk.js module, which should contain the Button export.

All imports are streamed first, then comes the serialized UI structure, which references imports via the $L tag (lazy reference). A serialized React element is basically like Hiccup; it's an array that starts with a $ marker, followed by the element type, key attribute, and an object of props.

Serialized promises are referenced with the $@ tag. The tag tells React where to place a future value, once it arrives.

async function getText() {
  await sleep(1000);
  return "hello";
}

<div>
  <span>{getText()}</span>
</div>;
0:["$","div",null,{"children":[["$","span",null,{"children":["$@1"]}]]}]
1:"hello"

This way, a server can send the initial payload immediately and stream other parts to the client later. With promises being a fundamental primitive for async values, it also becomes possible to stream async iterables to the client or even core.async channels. I'll share more on this later once we get to the Clojure implementation.

The Plan

Alright, that was a lengthy intro. Let's talk about potential challenges in porting server components to Clojure and UIx.

A serializer for the React Flight format is quite straightforward to implement. The missing build tooling that's aware of client/server separation might be tricky, but I think macros combined with cljc and reader tags can get me far. Everything else, like routing and web server integration, is clear and is just something that needs to be built with a good developer experience in mind.

My end goal is to port only useful parts of server components to UIx, so I expect that some of the features or optimizations will be missing.

Current state

As of now, I've completed the first iteration. Check out this Hacker News demo below that runs on Fly.io. If you inspect network activity in your browser's devtools on this page, you'll see the web app hitting /rsc endpoint when navigating the top section or opening comments. The Vote button is the only client component on this page. When pressed, it executes a server action that increments the number.

Let's have a look at some parts of the code, from a developer's perspective. Server components are normal UIx components, defined in .clj files. This one is fetching posts from the Hacker News public API and renders them as a list of story components.

(defui stories [{:keys [path]}]
  (let [data (services/fetch-stories path)]
    (for [d data]
      ($ story {:key (:id d) :data d}))))

The story component renders a bunch of elements and a voting button, which is a client component. Note that vote-btn takes a map of props. When serialized, the Flight payload will include a reference to the client component, together with the props map as an EDN string.

(defui story [{:keys [data]}]
  (let [{:keys [id by score time title url kids]} data]
    ...
    ($ ui/vote-btn {:id id :score score})
    ...))

UIx components become client-enabled when marked with ^:client meta tag. Those components can use all typical client-side stuff, like event handlers, NPM libraries, etc. Client components have to be defined in .cljc files, since they should be accessible to both Clojure and ClojureScript. The vote function called on button press is a server function that increments the score.

(defui ^:client vote-btn [{:keys [id score]}]
  ($ :button {:on-click #(actions/vote id score)}
    (str "Vote " score)))

Server functions are registered via defaction macro. In ClojureScript, server actions hit a backend API endpoint; in Clojure, they receive the client payload and execute the code. The return value goes back to the client. For the same reason as client components, actions are defined in .cljc files.

A server action can be anything: a simple number increment, a network request, or it may hit a database.

(defaction vote [id score]
  (inc score))
(defaction vote [id]
  (sql/exec {:update :stories,
             :set {:score [:+ :score 1]},
             :where [:= :id id]}))

And finally, the whole thing is rendered. The on-chunk function receives rows of the Flight payload as the UI tree is being serialized; then it's up to you to decide how to send the payload to the client.

(uix.rsc/render-to-flight-stream ($ root) {:on-chunk on-chunk})

Unlike existing opinionated implementations in the JavaScript world, for UIx, I want to make sure it's minimal enough so that you can plug in your favorite libraries for a web server, routing, etc. Right now, the code runs with httpkit, reitit, and core.async; none of that is final, and the public API might change a lot during development.

You can follow the progress in this PR: pitch-io/uix #241, and here's the Hacker News example project. Continue reading the second post, about server-side rendering and streaming.