Skip to Content
Back

React 19 - Part 2: The Code; How it works under the hood!

In the previous article, we delved into the backstory of how it all came to be: the numerous iterations and the twists and turns that ultimately led us to the final R19 prototype. Now, it's time to discuss the code, the setup, and how it all operates under the hood.

IMPORTANT:

All of the code snippets provided here are simplified versions of the actual code. The real code can be a bit more complex, and sometimes to be displayed in its entirety. If you're comfortable delving into the code, you can explore everything in the R19 repository.

Build Step

It all begins with the build step. The primary focus of this project was to create a framework that is both fast and simple to understand. For the aspect, I aimed to transpile and bundle the code ahead of time. This served as the initial step so that we could concentrate on the aspect during runtime.

The build script utilizes Bun as the bundler. With an API very similar to esbuild but faster and fully integrated with our runtime, it was an obvious choice. However, this doesn't imply that the process was easy or straightforward. The few differences between the two bundlers made the process slightly more challenging than anticipated, but ultimately, it was worthwhile.

The result was a build script. This script transpiles the code, bundles it, incorporates all necessary React 19 specifics, and outputs everything to the build directory.

The Pages

Firstly, let's address what needs to be built. In this case, it's essentially . Therefore, we categorize the files in the src directory into pages, components, and assets. The latter encompasses anything that is not a .ts or .tsx file, allowing us to support files like favicon.ico, robots.txt, static .css style sheets, and so on.

build.ts
const entries = (await readdir(resolve("src"), { recursive: true })).reduce(
  (acc, file) => { ... }, // Nothing too interesting here, just categorizing the files
  { pages: [], components: [], assets: [] }
)

Next, we move on to the pages. Here, we define our custom plugin, the rsc-register. This plugin will be invoked for every import call during the source loading process. This plugin plays an essential role in the React 19 flow, performing essentially the same function as the React "official" bundler plugins. It handles the RSC and RSA referencing. However, I'll refrain from delving deeper into this now; we'll discuss them later.

build.ts
const server = await Bun.build({
  target: "bun",
  entrypoints: entries["pages"],
  outdir: resolve("build", "app"),
  plugins: [
    {
      name: "rsc-register",
      setup(build) {
        // We'll talk about this one next...
      }
    }
  ]
});

NOTE:

There isn't a distinct separation between the "pages" and "components" builds. We only need to split them up due to how we handle imports. If we applied the plugin universally, we wouldn't be able to differentiate between imports and the actual source code.

The RSC Register

This is where the magic happens. Every file imported by our pages that matches the filter /\.ts?$/ (which essentially ensures we handle only local imports) will be processed by this plugin. Here's how it works: We read the file, check if there's a use client or use server directive. If there's none, we let it be bundled, resulting in the content being inlined. If there's a directive but no exports, we still let it be bundled. However, if there's a directive and exports, we create a reference to it.

IMPORTANT:

Here, we utilize the Bun.Transpiler to scan the file and list the exports. This is a useful feature of the Bun bundler, but it can also be achieved in Node.js using acorn-loose, as the official React plugins do.

build.ts
{
  name: "rsc-register",
  setup(build) {
    build.onLoad({ filter: /\.tsx?$/ }, async args => {
      const content = await Bun.file(args.path).text()
 
      const directives = content.match(/(?:^|\n|;)"use (client|server)";?/)
      if (!directives) return { contents: content } // If there are no directives, we let it be bundled
 
      const { exports } = new Bun.Transpiler({ loader: "tsx" }).scan(content)
      if (exports.length === 0) return { contents: content } // If there are no exports, we also let it be bundled
 
      return {
        contents: exports.map(e => createReference(e, args.path, directives[1])).join("\n")
      }
    })
  }
}

Finally, we register the reference. This is a new concept introduced by React 19. It's the way to inform the server or client that there's a client component needing import or render or a server action requiring invocation and state1 update. Below is a simplified explanation of how this is accomplished through a helper function:

build.ts
export const createReference = (e: string, path: string, directive: string) => {
  const id = `/${relative(".", path)
    .replace("src", "build")
    .replace(/\..+$/, ".js")}#${e}`; // React uses this to identify the component
  const mod = `${
    e === "default" ? parse(path).base.replace(/\..+$/, "") : ""
  }_${e}`; // We create a unique name for the component export
 
  return directive === "server"
    ? // In case the of a server action, we add properties to a mock up function to avoid shipping the code to the client
      `const ${mod}=()=>{throw new Error("This function is expected to only run on the server")};${mod}.$$typeof=Symbol.for("react.server.reference");${mod}.$$id="${id}";${mod}.$$bound=null;${
        e === "default"
          ? `export{${mod} as default}`
          : `export {${mod} as ${e}}`
      };`
    : `${
        e === "default" ? "export default {" : `export const ${e} = {`
      }$$typeof:Symbol.for("react.client.reference"),$$id:"${id}",$$async:true};`;
};

Yeah... It's not pretty at all. Let me try to explain it a little bit better.

The Components

Handling components is relatively straightforward. We simply need to bundle them, and that's it! There are no special cases to consider; we bundle them and output the result to the build directory.

build.ts
const client = await Bun.build({
  target: "bun",
  entrypoints: entries["components"],
  outdir: resolve("build")
});

The Assets

This category encompasses essentially anything else. Here, we could implement optimizations such as using sharp to compress images and postcss to minify and bundle styles. However, for now, we'll stick to copying the files to the build directory.

build.ts
entries["assets"].forEach((asset) =>
  Bun.write(asset.replace("src", "build"), Bun.file(asset))
);

NOTE:

While I'm not entirely sure about how other bundlers handle this, Bun provides a convenient API for handling assets. It automatically replaces import calls with the correct path, alleviating any concerns about path management.

How the Runtime Works

Now, let's delve into the real deal. Everything explained above was just setting the stage (albeit one of the most parts of the project). We're now moving on to discuss the runtime: how RSC and RSA are handled, how server state is managed, and more.

Here, we have two servers: rsc.ts and ssr.ts, each serving a different purpose, yet both are essential to each other.

NOTE:

There's ways to make all of this with one server, as Next.js does, but as Josh said:

I wanted to keep it simple, so I decided to split them up.

The RSC Server

This is the server that handles RSC payloads—the essence of React 19. It listens for both GET and POST requests on "/*". The GET request renders the page to an RSC stream:

rsc.ts
  .get("/*", async (req, res) => {
    let mod = (
      await import(resolve("build/app", `.${req.path}/page.js`))
    ).default(req.query); // We will use the query as props for the page
 
    renderToPipeableStream(mod, moduleBaseURL).pipe(res);
  })

It's responsible for managing server actions, decoding props, gathering results, and updating the UI with the new state. Additionally, it returns the new RSC stream.

rsc.ts
  .post("/*", bodyParser.text(), async (req, res) => {
    const actionReference = req.headers["rsa-reference"];
    const actionOrigin = req.headers["rsa-origin"];
 
    // Resolve the action
    const [filepath, name] = actionReference.split("#");
    const action = (await import(`.${resolve(filepath)}`))[name];
 
    let args = await decodeReply(req.body, moduleBaseURL); // Decode the arguments
 
    const returnValue = await action.apply(null, args); // Call the action
 
    const root = (
      await import(resolve("build/app", `.${actionOrigin}/page.js`))
    ).default(req.query); // We will use the query as props for the page
    renderToPipeableStream({ returnValue, root }, moduleBaseURL).pipe(res); // Render the app with the RSC, action result and the new root
  })

renderToPipeableStream

This is an interesting part of the code—it's the function responsible for rendering the RSC stream. While it may seem a bit complex when delving into the details (source), it's not too difficult to understand from the outside. Essentially, it creates a pipeable stream to render the RSC stream built from the ReactNode tree.

The SSR Server

This server acts as the front-facing server, handling GET requests from users . Hypothetically speaking, this server forwards the request using the node:http module, resulting in a stream—specifically, a NodeStream—which can be handled by "react-server-dom-esm/client.node".

Here, Layout refers to a global root shell used to wrap the RSC stream, containing setup such as head meta tags and global scripts. The Root represents the actual RSC stream, wrapped in a use hook, constituting the page content.

"renderToPipeableStream" in this context shares the same name as seen on the RSC server, but it's a distinct function. This one comes from "react-dom/server.node" and is responsible for taking the RSC, layout, props, and other necessary elements and rendering them to an HTML string.

ssr.ts
  .get("/*", async (req, res) => {
    const url = new URL(req.url, `http://${req.headers.host}`)
    url.port = "3001" // Forward to the SSR API server
 
    return http.get(url, async rsc => {
      let Root = () => use(createFromNodeStream(rsc, resolve("build/") + "/", moduleBaseURL)) // Create a root component from the RSC result
      const Layout = (await import(resolve("build/_layout"))).default // Load a HTML shell layout
 
      renderToPipeableStream(createElement(Layout, { children: createElement(Root) })).pipe(res) // Render the the element as html and send it to the client
    })
  })

And guess what? We're almost done!

Extras

Do you think those static paths and fixed module base URLs are a little bit too much? Yeah, me too. But instead of fixing them, I had another idea. What if we exported HTML? We already know how to render it, so why not? Anytime someone requests a page without any props, we'll just send the HTML. This way, we can have a static site with a dynamic server, client components, server actions, be as quick as possible, and still have good SEO.

ssr.ts
const pages = (await readdir(resolve("src"), { recursive: true }))
  .filter((file) => file.endsWith("page.tsx"))
  .map((page) => page.split("/").at(-2));
 
const exports = await Promise.all(
  pages.map(async (page) => [
    page,
    await (
      await fetch(`http://localhost:3000${page === "app" ? "/" : `/${page}`}`)
    ).text()
  ])
);
 
exports.map(([page, content]) =>
  Bun.write(`./build/static/${page === "app" ? "index" : page}.html`, content!)
);

Please, could you stop laughing? I know it's not the best solution, but it's a solution. And it works. And it's fast. And it's simple. And it's easy to understand. Well, it's a solution.

With a few adjustments to our ssr.ts, we can now serve the HTML files, and we're done!

ssr.ts
  .get("/*", async (req, res) => {
    // ...
    if (url.searchParams.length === 0) {
      const page = (req.path === "/" ? "index" : req.path.slice(1)) + ".html"
      log("Defaulting to static page:", `"${page}"`.green)
      try {
        return res.send(await Bun.file(resolve("build/static/", "./" + page)).text())
      } catch {
        log("File not found, falling back to SSR".dim)
      }
    }
    // ...
  })
 
new Worker(new URL("./export.ts", import.meta.url).href).addEventListener("close", _ =>
  log("Pages exported successfully! 🚀".bold)
)

We have a full stack: server-side rendered, client-side rendered, server actions, and static pages, all in one place, all in one project, all in one... well, you get it.

Conclusion

This was a long one, but I hope you enjoyed it. I hope you learned something. I hope you're excited about the future of React and web development. Personally, this was a great journey. I learned a lot. And besides all the PHP jokes, you know what? This seems like a really, and I mean really powerful model to think about web development. I'm really excited to see where it goes. I do think everyone, even those working at React, might have some pushbacks and problems with the size or how slow things can be. But you know what's even slower? Not trying at all.

See you all in my next months-long dubious quality project!


P.S.: Delba dropped this banger of a visualizer for the React 19 references, and how client components behave in each environment. It's a must-see!


Footnotes

  1. Dan discusses the concept of UI = f(data, state) in-depth in an article that I highly recommend reading.

1085
React 19 - Part 1: The Backstory; My journey writing a framework from scratch!

Get notified!

I'll only send updates with the best content. Trust me, I hate writing emails as much as you hate spam.

r19

A dead-simple React 19 "framework" implementation from scratch

#bun#esm#react#rsc

ds

Think "docker stats" but with beautiful, real-time charts into your terminal. 📊

#cli#docker#hacktoberfest#rust#stats

htnx

a htmx like experience in Next.js with RSC and quite a bit of questionable code.

#htmx#nextjs#react#rsc

quix

Supercharge Your VTEX IO Workflow with a Lightning-Fast CLI Alternative. Enhance Developer Experience and Boost Productivity.

#performance-analysis#rust-lang#vtex#vtex-io#wip

tailwindcss-expose-colors

Expose specific colors from your Tailwind CSS theme as CSS variables

#tailwind#tailwindcss#tailwindcss-plugin

nextjs-zustand-setup-ssr

The bare minimum for a Zustand setup with server-side data and client-side hydration in Next.js

#typescript
CC BY-NC 4.0|CMRG©