Everyone always asks about React Server Components (RSC) and why it needs two servers and a wire format. The two servers question I'm just going to say "different module graphs" and move on for now because for this post the wire format requirement is the interesting part.

When you render a React component, you render to an intermediate format referred to as the VDOM. The VDOM is "diffed" with the current screen and patches are applied to the real DOM so that it matches the new VDOM. This is called the reconciliation process and it's the mechanism that allows for "state" via hooks in pure functional components. It's quite beautiful.

Now how do we get a server rendered <div> to reconcile? React's answer with the wire format is essentially "send the server rendered VDOM for reconciliation". This allows each client to render / reconcile as fit for the purpose at hand. SSR: render VDOM to HTML. Browser: reconcile real DOM. This is also the mechanism that allows for "use client" components to be rendered by the server. Instead of expanding the client component to VDOM on the server, instructions on how to load it are encoded and consumed by the clients.

The last question is "how do you introduce async components that can load data and only run once"? The simple and effective solution again is to run it on the server and serialize them to an asynchronous format.

So the answer to "why do we need a wire format in React?" at a high level can mostly be broken down to:

  • Reconciliation

  • Hydrate components in the client environment

  • Async data loading components

What if we didn't need to hydrate components?

We can do a lot today with declarative markup and small inline event handlers.

  • Structure & Behavior: <dialog>, popover, <details name="...">, HTML validation.

  • Positioning & Motion: Anchor Positioning, View Transitions, Scroll-driven animations.

  • State & Styling: CSS :has(), :user-invalid, Declarative Shadow DOM.

  • Routing & Data: Navigation API, Declarative Partial Updates.

We don't want to loose our component model though. So let's relegate it to the server.

renderToReadableStream(
  <html>
    <head>
      <title>Hello, World!</title>
    </head>
    <body>
      <h1>Hello, World</h1>
      <Counter />
    </body>
  </html>
)

If we don't have components that hydrate in the browser, how can we have a Counter component? Inline event handlers, an oldie but goodie.

function Counter() {
  return (
    <button
      onclick={`
        (() => {
          event.preventDefault();
          const self = event.currentTarget;
          const span = self.querySelector("span");
          const count = parseInt(span.textContent);
          span.textContent = (count + 1).toString();
        })();
      `}
    >
      Count <span>0</span>
    </button>
  )
}

That... works? I guess... It get's serialized and runs, but we have no type checking and a big ugly string in the middle of our code.

What if we introduce the idea of serializable event handlers and marked them as such?

function Counter() {
  return (
    <button
      onclick={(event) => {
        "use client";
        event.preventDefault();
        const self = event.currentTarget as HTMLButtonElement;
        const span = self.querySelector("span") as HTMLSpanElement;
        const count = parseInt(span.textContent);
        span.textContent = (count + 1).toString();
      }}
    >
      Count <span>0</span>
    </button>
  )
}

Now we are getting somewhere.

What about dynamically loading data?

<button
  onclick={(event) => {
    "use client";
    const button = event.currentTarget as HTMLButtonElement;
    const target = button.nextElementSibling as HTMLDivElement;
    if (target.hasAttribute("data-loading")) return;

    target.setAttribute("data-loading", "");
    button.textContent = "Loading...";

    fetch(routes.partial.href())
      .then((res) => {
        if (!res.ok) throw new Error("Bad response");
        return res.text();
      })
      .then((html) => {
        target.setHTMLUnsafe(html);
      })
      .catch(() => {
        target.innerHTML = "<p>Failed to load</p>";
      })
      .finally(() => {
        target.removeAttribute("data-loading");
        button.textContent = "Load Partial";
      });
  }}
>
  Load Partial
</button>
<div></div>

Fetching from the server and replacing content is a viable way to build reasonably complex apps, then with inline event handlers and native event bubbling you can go quite far.

What about initialization code and getting an element ref? Welp, we have the ref attribute.

<canvas
  ref={(canvas) => {
    "use client";
    setupThreeJsApp(canvas);
  }}
/>

Not exciting?

That's kinda the point. A page ships zero JS by default. Polyfills for behavior shown above and streaming are ~12.5 KB uncompressed. These will disappear over-time, so not only is this an unexciting library, it's one where it's requirements will disappear over time.

The code you write for event handlers on the other hand can live forever, regardless of if you are using srv-jsx and use client, or vanilla JS.

Want to give this a try?

Clone the example:

pnpm dlx giget@latest gh:jacob-ebey/srv-jsx/skills/srv-jsx/assets/vite-example

Run the dev server:

pnpm dev

Install the agent skill if that's your cup of tea:

pnpm dlx skills@latest add jacob-ebey/srv-jsx