Under the Hood: Hydrating React Components in Java

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 byjsx('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!:
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.