Under the Hood: Hydrating React Components in Java

Under the Hood Hydrating React Components in Java.jpg
Jahia is the only DXP that truly empowers you to deliver personalized journeys powered by customer data Learn how here

Jahia is a Java CMS made unique by its ability to render and hydrate React components on the server and client side, and how we implemented the Island Architecture in Java. The goal of this article is to explain how we built these features with deep technical details.

This article is voluntarily technical, and not meant to be a tutorial. The goal is to take a look under the hood of our implementation, and understand how we made it work. If you are looking for a more practical introduction to the Island Architecture, we recommend reading our previous article Leveraging the Island Architecture in Jahia CMS on the subject.

As an open-source CMS, we value the importance of sharing and documenting our work. All the techniques used in this article are actually implemented in the Jahia JavaScript Modules repository, and used in production by us and our customers.

Running JavaScript in Java 

We implemented the JavaScript Modules engine in Jahia on top of the GraalVM engine. It allows running JavaScript code in a Java environment, and offers a good interoperability between Java and JavaScript.

The minimal code to run a JavaScript snippet in Java looks like this:

import org.graalvm.polyglot.*;

public class JavaScriptRunner {
  public static void main(String[] args) {
    Context context = Context.newBuilder("js").build();
    context.eval("console.log('Hello from JavaScript!');");
  }
}

Running JavaScript as a single eval creates a whole world of challenges. JavaScript projects are never written as a single file, but rather as a collection of modules. The operation of transforming a collection of modules into a single file is called bundling. The bundler we use in the JavaScript Modules ecosystem is Vite, but for the rest of this article, we will use Rolldown as we don't need as many features as Vite provides.

With the right context configuration, you can enable interactions between Java and JavaScript. That's how we share common libraries between different JavaScript modules, and how we added the ability to call OSGi services from JavaScript.

Running React in JavaScript in Java

We will try to build a basic version of the server-side rendering process, followed by the hydration process. Hydrating a React component means attaching event listeners to existing HTML elements, and re-rendering the component when the state changes.

Let's start by rendering a React component on the server. We want to render a basic Hello World page, written in React, and output the HTML document as a string. The code looks like this:

// `renderToString` transforms a React component into a string of HTML
import { renderToString } from 'react-dom/server.edge';

// Hello World! page component
const page = (
  <html>
    <head>
      <title>My page</title>
    </head>
    <body>
      <h1>Hello World!</h1>
    </body>
  </html>
);

// Transform the React component into a string of HTML
const response = '<!DOCTYPE html>' + renderToString(page);
console.log(response);

The renderToString function is part of the react-dom/server.edge module, which is a server-side rendering API provided by React. It transforms a React component tree into a string of HTML, which can then be sent to the browser (or printed in the console, as we do here).

We cannot run this code directly in Java, as it uses import statements and JSX syntax. We need to transpile it to a format that can be executed by GraalVM.

Using Rolldown to bundle the code can be done as a single command: npx rolldown@canary index.jsx -o dist/index.js. (If you actually want to run this, you'll need to create a Node.js project with react and react-dom as dependencies.)

This will produce the following code:

//#region node_modules/react-dom/server.edge.js
var require_server_edge = __commonJS({
  'node_modules/react-dom/server.edge.js'(exports) {
    // ...
  },
});
//#endregion

//#region node_modules/react/jsx-runtime.js
var require_jsx_runtime = __commonJS({
  'node_modules/react/jsx-runtime.js'(exports, module) {
    // ...
  },
});
//#endregion

//#region index.jsx
var { renderToString } = __toESM(require_server_edge());
var { jsxs, jsx } = __toESM(require_jsx_runtime());
const page = jsxs('html', {
  children: [
    jsx('head', {
      children: jsx('title', { children: 'My page' }),
    }),
    jsx('body', {
      children: jsx('h1', { children: 'Hello World!' }),
    }),
  ],
});
const response = '<!DOCTYPE html>' + renderToString(page);
console.log(response);
//#endregion

This is barely readable, but let's break it down:

  • Our import statements were replaced by the actual code of the modules we imported. Bundling allows shipping all the code in a single file, which is what we need to run it in a single eval in GraalVM.

  • The JSX syntax was replaced by function calls. For instance <h1>Hello World!</h1> was replaced by jsx('h1', { children: 'Hello World!' }). This is called transpilation: JSX is not valid JavaScript, so we need to convert it to a valid format that can be executed by the JavaScript engine. React chose to use function calls to represent JSX elements.

We can see our response variable at the end!

Running this code in GraalVM (or any other JavaScript engine in fact, we removed all runtime-specific code) will produce the following output:

<!DOCTYPE html>
<html>
  <head>
    <title>My page</title>
  </head>
  <body>
    <h1>Hello World!</h1>
  </body>
</html>

Yay! 🎉

This valid HTML document can now be sent to the browser, which means that we successfully rendered a React component on the server! The next step is to hydrate it on the client side.

Hydrating React Components on the Client

Let's extend our previous example to add a dynamic component that will be hydrated on the client side. It will be this simple button:

export default function Button() {
  return (
    <button
      onClick={() => {
        alert('Button clicked!');
      }}
    >
      Click me
    </button>
  );
}

We now need two outputs: one that runs on the server, and one that runs on the client. We can update our initial server code to include our button component:

import { renderToString } from 'react-dom/server.edge';
import Button from './Button.jsx'; // Import the Button component

const page = (
  <html>
    <head>
      <title>My page</title>
    </head>
    <body>
      <h1>Hello World!</h1>
      {/* This button will be rendered on the server, hydrated on the client: */}
      <div data-hydrate>
        <Button />
      </div>
      {/* This script will be executed on the client: */}
      <script src="client.js"></script>
    </body>
  </html>

The client code (referenced in the <script> tag), before being bundled and transpiled, looks like this:

import { hydrateRoot } from 'react-dom/client';
import Button from './Button.jsx';

hydrateRoot(document.querySelector('[data-hydrate]'), <Button />);

Notice how we import the same Button component that we used on the server... This is the key to Island Architecture: we use the same React component on both the server and the client. This is one of the many benefits of hydration over Server-Side Rendering (SSR) with a bit of jQuery: having the server and client code in the same language allows for a more maintainable codebase in the long run.

hydrateRoot is a function from the react-dom/client module that unifies a server-rendered React component with its client-side counterpart. It takes two arguments: the DOM element to hydrate, and the React component to render. What it does in practice is attach event listeners to existing HTML elements, and re-render the component when the state changes.

We now have two entry points: index.jsx for the server, and client.jsx for the client. We need to bundle them separately:

npx rolldown@canary index.jsx -o dist/index.js
npx rolldown@canary client.jsx -o dist/client.js

We won't review the files—they are not very readable—but they contain all that's needed to run on the server and the client respectively.

We can test that the hydration works by serving the HTML file generated by dist/index.js:

# Save the HTML output to a file
node dist/index.js > dist/index.html
# Expose the `dist` directory using a static file server
npx sirv-cli dist

Opening localhost:8080 will display a magnificent "Hello World!" page with a button. Clicking the button will show an alert saying Button clicked!:

interactive-hello-world.png

The button was properly hydrated (✨), leaving the rest of the page intact. We re-created the partial hydration process that we described in our previous article on the Island Architecture.

Conclusion

While more complex, the implementation we did at Jahia works roughly the same; we use a data-hydration-mode attribute to identify the elements that need to be hydrated, not the full page. We also leverage an importmap to share libraries between all client-side components, to avoid shipping React once per component.

We hope this technical article was interesting to you, and that it helped you understand how we implemented the Island Architecture in Jahia. If you want to learn more about JavaScript Modules, we recommend reading our JavaScript Modules introduction to get you started with the basics.

Gautier Ben Aïm
Gautier Ben Aïm

Gautier Ben Aïm is Developer Advocate at Jahia, where he acts as an interface between internal and external technical teams. He helps make Jahia's CMS, DXP, and DAM solutions more accessible, understandable, and adopted by integrators, partners, and customers, while promoting an open and documented engineering culture.

His area of expertise is web development (open source!) with a particular emphasis on knowledge sharing and transfer. He is committed to lifelong learning and stays at the forefront of industry trends through diligent technology monitoring.

He is proficient in development, software architecture, and IT security, thanks to his previous professional experience in startups specializing in cybersecurity: offensive security and cryptography. His career reflects his passion for IT, which he discovered as a child.

https://www.linkedin.com/in/gautier-ben-aim

https://github.com/GauBen

Back