Blogs
Want to transform your React app into a Client Extension? I'll show you how in this post...
Introduction
So I've been working on my Objects Rule! series and have been working on an app I plan on showing off soon, but I realized that there is probably a gap explaining, well, how do you get started?
I mean, it's one thing to go and check the client extensions
sample repo here: https://github.com/liferay/liferay-portal/tree/master/workspaces/liferay-sample-workspace,
find the sample React custom elements such as
liferay-sample-custom-element-2
(a simple React CX),
liferay-sample-custom-element-4
(a CX using Liferay's
React), or liferay-sample-custom-element-5
(a CX using
Liferay's React and Clay), copy them and try to get them to work...
But that's actually kind of hard and might be prone to problems...
It's hard because if you need to include some other library, well how do you do that if you're using Liferay's React? How will it reconcile and load the dependencies?
And if Liferay's React changes version in the next quarterly release, will your custom element continue to work or will it require an update? How will you know if your custom element starts failing because of a Liferay React version bump?
And besides, if you're a React developer (not necessarily a Liferay developer), you'll likely want to use standard tooling you're familiar with to create your React application, not copying and modifying Liferay's custom element...
So in this blog, I'm starting from the perspective of "I'm an experienced React developer who uses standard React tools, how can I get these working under Liferay?"
Hopefully this will prove to be useful to some of you out there...
Preparing a Workspace
Since I'm an experienced React developer but maybe new to Liferay, some of what follows is going to be new to me, but it is required to develop for Liferay.
First thing I need is to have what is called a Liferay Gradle Workspace:
- Workspace because it contains all Liferay customizations and applications and coordinates development, builds and testing with Liferay.
- Gradle because Liferay is a Java application and the Liferay tooling all leverages Gradle for building the various artifacts, including our React custom elements.
- Liferay because, well, we're going to be deploying to Liferay.
So how do we get a Liferay Gradle Workspace for our React elements?
Well, the steps to get a Liferay Gradle Workspace are going to be strange, and it's even strange for Java developers, but it's what is necessary...
We need to copy the liferay-sample-workspace
that
we can find in the Liferay Portal Github repo here: https://github.com/liferay/liferay-portal/tree/master/workspaces/liferay-sample-workspace
It's not available as a separate repository, so we can't just download the zip of that folder alone. And the Liferay Portal repo is huge, so we don't want to clone or fork it either.
The easiest method is to just go to https://github.com/liferay/liferay-portal/,
download the ZIP from the master branch, expand it and then copy the
whole liferay-sample-workspace
folder to some location on
your drive where you plan to put all of your React elements.
I'm going to copy mine as liferay-react-workspace
for the rest of this blog.
The last thing we'll do is edit the
liferay-react-workspace/gradle.properties file, and we're going to
change the value for the liferay.workspace.product
key to
portal-7.4-ga112
so we can use Liferay CE. If using DXP,
just set the value to the appropriate version of the bundle that
you're targeting.
So, why have we done all of this?
The Liferay Gradle Workspace contains special tooling that knows how to package and bundle custom React elements into React Client Extensions. The steps we've done here will give us this tooling so we can package our custom elements. Even though we may not know Gradle or any of the other aspects of the Liferay Gradle Workspace, we'll learn enough going through this blog to get the custom React elements developed, built, deployed and tested in Liferay.
Now that the prep work is done, we can get back to the main topic...
Creating a New Project
For the purposes of this blog, we're going to create a new React application. As an experienced React developer, I probably already have an app that I've created and that I want to turn into a React Client Extension (CX), but I'm going to start a new one so that you too will be able to do the same on your next React CX...
Change to the
liferay-react-workspace/client-extensions
directory.
So, first thing we're going to do is create a new React app by issuing the following command:
$ yarn create react-app my-react-app
And like John Oliver on Last Week Tonight, I have
tricked you! You may not have known that
create-react-app
was deprecated in 2023 and I just used
it in 2024 to create a new React project!
Okay, okay, I'm not as funny as John Oliver. An experienced React developer would really be using the command:
$ yarn create vite my-react-app --template react
Vite
is the new trendy way to create a
React app, although there are others such as Next.js or Remix.
Either way, we're going to end up with a new React project that we will be adapting to become a React CX, and either way I'll cover the changes that we need to make to leverage the React app starting point.
If you already have existing React apps to migrate, begin from this point...
Step 1: Update package.json
The package.json
is the key for your React app and,
for the most part, whatever you have in there is going to be just fine.
Well, with one exception - the name.
The name of your React app as defined in
package.json
will turn into the custom element name,
and in Liferay, all custom element names must be unique.
For Liferay-created elements, you'll typically find the names
are defined like @liferay/descriptive-element-tag-name
format to ensure they will be unique and also distinguishable from
your own. Using the @liferay
scope ensures that their
descriptive-element-tag-name
will not collide with your
own React app named descriptive-element-tag-name
.
If you've created the my-react-app
as specified in
the previous section, this is likely not going to be unique,
especially since every React example out there has you calling the
project my-react-app
.
So we'll set ours to my-react-custom-element
and,
for good measure, let's put it in the @liferay
scope.
We really shouldn't do this since we don't own the
@liferay
scope, but I don't think they'll mind for the
purposes of this blog.
By default, new projects created using
create-react-app
or vite
will use React 18.
This is totally fine, but if you want to use Liferay's version of
React, you must change to version 16.
There are some things in package.json
that you
might want to change, but that's up to you. For example, you might
pull out the test dependencies, remove the linter, and clean up
other things you don't need. For a minimalist
package.json
example, check this one from the Liferay
sample workspace: https://github.com/liferay/liferay-portal/blob/master/workspaces/liferay-sample-workspace/client-extensions/liferay-sample-custom-element-2/package.json
Step 2: Update index.html
So we changed the name in the package.json
, we need
to make sure that our custom tag is listed correctly in
public/index.html
or index.html
(depending
upon if you used create-react-app
or vite
to
create your application).
Edit the file and look for the line that reads:
<div id="root"></div>
This line, we'll need to replace with:
<my-react-custom-element></my-react-custom-element>
Most React apps are Single Page Applications (SPAs), so there is typically only one root div to worry about. In Liferay where we may have multiple React custom elements, we need a unique identifier so our custom element does not get clobbered by some other React custom element.
If you will be running your React application outside of Liferay
(i.e. for development and testing) and you are using Clay, you should
add the following to the <head>
area:
<link href="https://cdn.jsdelivr.net/npm/@clayui/css/lib/css/atlas.css" rel="stylesheet" />
This will pull in the default Clay styles so your Clay components render correctly.
Step 3: Change Connecting Logic
This next change will be in src/main.jsx
I'll show
the change we're going to make, then explain it...
We're going to chop the code out that is standard:
create-react-app:
import reportWebVitals from './reportWebVitals'; const root = ReactDOM.createRoot(document.getElementById('root')); root.render( <React.StrictMode> <App /> </React.StrictMode> ); reportWebVitals();
vite:
import ReactDOM from 'react-dom/client' ReactDOM.createRoot(document.getElementById('root')).render( <React.StrictMode> <App /> </React.StrictMode>, )
and replace this code with:
import {render, unmountComponentAtNode} from 'react-dom'; class WebComponent extends HTMLElement { connectedCallback() { render(<React.StrictMode> <App /> </React.StrictMode>, this); } disconnectedCallback() { unmountComponentAtNode(this); } } const ELEMENT_NAME = 'my-react-custom-element'; if (customElements.get(ELEMENT_NAME)) { // eslint-disable-next-line no-console console.log(`Skipping registration for <${ELEMENT_NAME}> (already registered)`); } else { customElements.define(ELEMENT_NAME, WebComponent); }
The chunk of code that we removed was the boilerplate code that
create-react-app
or vite
gives us in a new
React application. It is responsible for connecting the React
application to the root div declared in index.html
.
However, this kind of thing won't work for us on a Liferay page because of the dynamic nature of Liferay. We need a bit more to get the connection right and support the way Liferay renders pages.
Hence the replacement code...
First we create a special component called
WebComponent
that extends the standard
HTMLElement
and uses the connectedCallback()
and disconnectedCallback()
to manage what happens when
the custom React element is added to or removed from a page.
When the React custom element is added to a page, the
connectedCallback()
method creates a new root element,
and then has React render the same application from the generated
code, but into this new root element.
The disconnectedCallback()
gets invoked when the
React custom element is no longer on the page. Since Liferay will be
dynamically changing the page, our custom React element can be added
or removed numerous times. When the remove happens and the disconnect
is triggered, this callback will have React unmount the custom
application, basically freeing up any memory that the component
occupied. If we didn't do this, we would be generating a memory leak
in the browser that, over time, will cause problems for the end user.
The final step in the replacement code is to define the React
custom elements so when the browser sees the
<my-react-custom-element>
tag, it will know to use
the WebComponent
to handle rendering the application.
Step 4: Create the
client-extension.yaml
File
The next step is to create a special file that Liferay uses when building and deploying the custom React element Client Extension. It is this file which is the key to this being a CX that Liferay can handle. It is also a signal to the Liferay Gradle Workspace that this project can be built into a CX zip file suitable for deployment to Liferay.
Here's the client-extension.yaml file that we would use for the
create-react-app
version:
assemble: - from: build/static into: static my-react-custom-element: cssURLs: - css/main.*.css friendlyURLMapping: my-react-custom-element htmlElementName: my-react-custom-element instanceable: false name: My React Custom Element portletCategoryName: category.client-extensions type: customElement urls: - js/main.*.js useESM: true
And here's the client-extension.yaml file that we would use for
the vite
version:
assemble: - from: vite-build into: static my-react-custom-element: cssURLs: - style.css friendlyURLMapping: my-react-custom-element htmlElementName: my-react-custom-element instanceable: false name: My React Custom Element portletCategoryName: category.client-extensions type: customElement urls: - assets/*.js useESM: true
As you can see, they really are very similar...
The assemble block identifies where the build assets are coming
from, for create-react-app
they end up in
build/static
, and in vite
they end up in
vite-build
(because of the Vite
config in
Step 5). In both cases, we are locating the assets in the static folder.
The my-react-custom-element
block has many
similarities. The name of the block should always match the
friendlyURLMapping
and htmlElementName
, and
the name
should be set to something meaningful (this is
the name that will show up in the Liferay UI when page editors are
placing your React custom element on the page).
The differences for the urls stem from how the build artifacts
are arranged. In create-react-app
,
build/static/js
is where the main compiled JS file is
(the wildcard matches the random name it will have), etc. For
vite
, it is under the assets
folder.
Step 5: Update Vite Configuration
So this step doesn't apply to the create-react-app
folks, this is only for vite
.
We need to update the configuration so vite can do the build correctly.
We need to change from the default vite.config.js
:
import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [react()], })
to the updated vite.config.js
:
import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' export default defineConfig({ base: '/o/my-react-custom-element', build: { outDir: './vite-build', rollupOptions: { external: [ 'react', 'react-dom', /^(?!@clayui\/css)@clayui.*$/, ], } }, plugins: [ react({ jsxRuntime: 'classic', }), ] })
The base
sets the base URL that Liferay will be
using to serve the assets, so
/o/my-react-custom-element
path has the suffix to match the
name we've been using everywhere else. If our project name is
different from the element name, such as earlier where we had
my-react-app
, the base URL would be
/o/my-react-app
. This can be confusing and is one of the
reasons having the project name match the custom element name will
make things easier for us.
The build
configuration overrides where the build
occurs, this has to be changed to avoid conflicts with the Liferay
build process. In this case we're using vite-build
, and
this matches the assembly from the client-extension.yaml
file we just created. The rollupOptions
externals
list the modules provided outside of our build
that don't need to be directly included. Here we list React, React DOM
and the Clay artifacts; this means we'd be leveraging Liferay's
versions. We could just as well go without the externals
and include whatever version of React, Clay, etc. that we wanted to.
externals
excluding React, we must be using React 16 in
the package.json
file. To use React 18, we would need to
remove the rollupOptions
and include React 18 in our project.The plugins
sets the JSX runtime to Classic, or
React 16. This is the version that Liferay uses, so it will allow
you to leverage React from Liferay.
plugins
or the
rollupOptions
and can use React 18 in the
package.json
file.Building and Deploying the React Client Extension
So your 5 easy steps to migrate our React app into a React
Client Extension (CX) are now done (4 steps if you're using create-react-app
).
We need to build the deployment artifact that Liferay expects, and we're going to have to use the Java developer's toolset to get this part done.
In a terminal window open to
client-extensions/my-react-custom-element
, we're going to
issue the command:
$ ../../gradlew build
This will actually build the distributable CX zip file, which in
our case will be dist/my-react-custom-element.zip
. If we
had the project name different than the element name i.e.
my-react-app
, then we'd find a
dist/my-react-app.zip
file for distribution.
This is the zip file that we need to deploy to Liferay. We can
hand it off to the Liferay administrator and they'll drop it into
the osgi/client-extensions
folder if running Liferay
PaaS or Liferay Self Hosted. If we're on Liferay SaaS, the Liferay
administrator would use the command line tool to upload the zip file
to Liferay SaaS for deployment.
Since our React app is a full-blown React application, we can
run it locally like we would any other React application; the
scripts are there to run it locally, and for all of the changes
we've made, the custom element will still be placed properly in
index.html
when rendered and function as expected.
We can also test it locally in Liferay too. That way we can see just how our custom element will work in Liferay.
Use the following command from the root of the Liferay Gradle Workspace:
$ ./gradlew initBundle
This will set up a local Liferay instance in the
bundles
directory that is ready to run (you can get help
from your fellow Liferay developers to know how to start the Tomcat
application server).
When this bundle is running, from the
client-extensions/my-react-custom-element
directory you
can use the command:
$ ../../gradlew deploy
This will build and deploy the client extension into Tomcat in
bundles/osgi/client-extensions
so you can switch to your
browser window and place and play with your React custom element
within Liferay.
Conclusion
Whether you're an experienced React developer who needs to build an application to run on Liferay, or you're an experienced Liferay developer looking to do more React development, hopefully this blog will provide the details you need to be able to adapt your React app so it can run under Liferay.
Your Liferay Gradle Workspace now has your React custom element, but there's some other custom element client extensions to look at (some using React, one using Angular). You can check out the other client extensions in the directory or delete them all and only keep your custom React elements around, it's entirely up to you.
Have any questions or comments? Drop them below or find me on the Liferay Community Slack!