Creating Your First Custom Element: A Step-by-Step Tutorial (React Weather Card)
What You'll Build
In this tutorial, you'll build a Weather Card custom element — a standard web component, written in React 18 and styled with Clay UI, that fetches live weather for a configurable city and renders it inside a Liferay page. By the end, you'll be able to drop three instances of the same component onto a page — one for Honolulu, one for Fairbanks, one for New York — each showing different data from the same deployed code.
This tutorial pulls together everything covered in the earlier articles in this series: globalCSS and globalJS for the deployment pattern, jsImportMapsEntry for sharing React across the component, and now a full customElement client extension to tie it all together.
Reading time: 15-17 minutes · Difficulty: Intermediate · Audience: Frontend Developers, React Developers
What a Custom Element Actually Is
A custom element is a standard web component — built to the W3C Custom Elements specification — packaged together with a client-extension.yaml descriptor, zipped, and deployed to Liferay. Because it's a standard web component under the hood, you can build one with any framework that compiles down to a custom element: React, Vue, Angular, Lit, or plain JavaScript. Liferay doesn't care which framework you used — it only cares that the output registers a valid custom HTML element.
Prerequisites
- Node.js and npm installed
- Liferay Workspace set up locally
- Java installed (per your Liferay version's compatibility matrix)
- Blade CLI installed
- A running Liferay DXP instance to test against
- Familiarity with React function components and hooks
Step 1: Scaffold the Project
Inside your Liferay Workspace's client-extensions/ folder:
cd client-extensions
mkdir weather-card-custom-element
cd weather-card-custom-element
npm create vite@latest . -- --template react
This gives you a standard Vite + React starting point. You'll now adapt it into a custom element rather than a typical single-page app.
Delete the boilerplate you don't need — the default App.css, the sample counter logic, and any starter assets Vite scaffolds that aren't relevant to a weather widget.
Step 2: Pin React to Liferay's Bundled Version
Liferay ships with React 18. Vite's default template often scaffolds against the latest major version, which may be newer. Open package.json and make sure both dependencies and devDependencies are pinned to React 18:
{
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0"
}
}
This matters because your component will run against the React runtime Liferay provides via its JS import map — not a bundled copy. A version mismatch between what you developed against and what Liferay actually serves at runtime is a common source of hard-to-debug errors.
Step 3: Add Clay UI Components
Install Clay's Card and Loading Indicator components, along with the Vite plugin needed to build a proper web component output:
npm install @clayui/card @clayui/loading-indicator
npm install --save-dev @originjs/vite-plugin-federation
Clay UI is Liferay's own design system — using it keeps your custom element visually consistent with the rest of the platform without hand-rolling your own card and spinner styles.
Step 4: Configure Vite for a Web Component Build
A custom element isn't a typical React SPA — it needs to build to a single entry point that registers a web component, and it needs to not bundle React, ReactDOM, or Clay, since those will be supplied by Liferay at runtime via JS import maps.
vite.config.js:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
base: '/o/client-extension/weather-card-custom-element/static/',
build: {
lib: {
entry: 'src/index.jsx',
formats: ['es'],
fileName: () => 'index.js',
},
rollupOptions: {
external: ['react', 'react-dom', '@clayui/card', '@clayui/loading-indicator'],
output: {
assetFileNames: 'index.[ext]',
},
},
},
});
What this configuration does:
| Setting | Purpose |
|---|---|
base |
Defines the path where the component's built assets will actually be served from once deployed |
build.lib.entry |
Points to a single entry file rather than an index.html, since this isn't a standalone page |
rollupOptions.external |
Tells Rollup (Vite's bundler) to not include React, ReactDOM, or Clay in the output — these are marked external and resolved at runtime instead |
Declaring these as external is what makes the eventual jsImportMapsEntry pairing meaningful — without it, every custom element would re-bundle its own copy of React, exactly the problem the import map pattern exists to solve.
Step 5: Build the Component (and a Lesson in Web Component Lifecycle)
Here's where it's worth walking through the component in stages, because the mistakes along the way are as instructive as the final result.
Attempt 1: Hardcoded City (don't do this)
export default function WeatherCard() {
const city = 'New York'; // hardcoded — bad
// ...
}
This works, but changing the city means editing code and redeploying. Not acceptable for a component meant to be dropped into multiple page slots with different configurations.
Attempt 2: Configurable City, Missing Cleanup
import { useState, useEffect } from 'react';
export default function WeatherCard({ city }) {
const [weather, setWeather] = useState(null);
useEffect(() => {
fetch(`/api/weather?city=${encodeURIComponent(city)}`)
.then((r) => r.json())
.then((data) => setWeather(data));
}, [city]);
return (
<div className="weather-card">
{weather && (
<>
<h3>{weather.city}</h3>
<p>{weather.temp}°F</p>
<p>{weather.condition}</p>
</>
)}
</div>
);
}
Better — the city is now a prop, not hardcoded. But this version is still incomplete: it has no cleanup logic for when the component is removed from the page. In a Liferay portal, users navigate between pages via client-side routing (Senna.js), which means components get connected and disconnected repeatedly without a full page reload. Without proper cleanup, in-flight fetches can resolve after the component is already gone, potentially causing memory leaks or React state update warnings.
Attempt 3: The Complete Version — With disconnectedCallback
Since a custom element is a full web component, it has its own lifecycle separate from React's — connectedCallback and disconnectedCallback. When you wrap a React app inside a custom element, you're responsible for wiring these up yourself:
src/weatherCard.jsx:
import React, { useState, useEffect } from 'react';
import ReactDOM from 'react-dom/client';
import { Card } from '@clayui/card';
import ClayLoadingIndicator from '@clayui/loading-indicator';
function WeatherCardApp({ city }) {
const [weather, setWeather] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetch(`/api/weather?city=${encodeURIComponent(city)}`)
.then((r) => r.json())
.then((data) => {
if (!cancelled) {
setWeather(data);
setLoading(false);
}
})
.catch(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [city]);
if (loading) {
return <ClayLoadingIndicator />;
}
return (
<Card className="weather-card">
<Card.Body>
<Card.Description displayType="title">{weather?.city ?? city}</Card.Description>
<Card.Description displayType="text">{weather?.temp}°F</Card.Description>
<Card.Description displayType="subtitle">{weather?.condition}</Card.Description>
</Card.Body>
</Card>
);
}
class WeatherCard extends HTMLElement {
connectedCallback() {
const city = this.getAttribute('city') || 'New York';
this._root = ReactDOM.createRoot(this);
this._root.render(<WeatherCardApp city={city} />);
}
disconnectedCallback() {
if (this._root) {
this._root.unmount();
this._root = null;
}
}
}
export default WeatherCard;
Why disconnectedCallback matters here: without it, ReactDOM.createRoot keeps a React root alive after the element is removed from the DOM. In a portal where users navigate frequently via client-side routing rather than full page reloads, unmounted-but-not-cleaned-up React roots accumulate — a slow, hard-to-diagnose memory leak. Calling this._root.unmount() in disconnectedCallback ensures React tears down its internal state the moment the browser removes the element, exactly mirroring what would happen on a full page unload.
This is a common omission in AI-generated or quickly scaffolded custom element code — it's easy to get a component that works correctly on first render, and only discover the missing cleanup once you're navigating repeatedly between pages in a real portal session.
Step 6: Register the Custom Element
src/index.jsx:
import WeatherCard from './weatherCard';
if (!customElements.get('weather-card')) {
customElements.define('weather-card', WeatherCard);
}
The customElements.get() check prevents a duplicate registration error if the module happens to be evaluated more than once — a defensive habit worth keeping in any custom element entry point.
Step 7: Configure client-extension.yaml
assemble:
- from: dist
into: static
weather-card-custom-element:
name: Weather Card
type: customElement
htmlElementName: weather-card
instanceable: true
friendlyURLMapping: weather-card
portletCategoryName: category.client-extensions
urls:
- index.js
useESM: true
weather-card-import-map:
name: Weather Card Import Map
type: jsImportMapsEntry
bareSpecifier: weather-card-custom-element
url: index.js
Field breakdown for the customElement block:
| Field | Purpose |
|---|---|
htmlElementName |
The custom HTML tag name — must match exactly what you passed to customElements.define() |
instanceable |
true since you want multiple copies of this card on the same page, each with a different city |
urls |
The JavaScript file(s) containing the custom element's registration code |
useESM |
true, since the Vite build outputs an ES module |
friendlyURLMapping |
A readable identifier used when referencing this extension elsewhere |
The second block, weather-card-import-map, registers the custom element itself as a jsImportMapsEntry — the pattern covered in the previous article in this series. This isn't strictly required to make the Weather Card work as a page widget, but it makes the component easy to reference by import statement from inside a custom fragment later, rather than needing to hunt down and paste a generated resource URL every time the build hash changes.
Step 8: Build and Deploy
npm run build
blade gw deploy
Watch for the build successful message from the Vite build step, then tail your Liferay server logs:
tail -f LIFERAY_HOME/logs/catalina.out | grep weather-card
A STARTED message referencing your client extension's bundle confirms it deployed and initialized correctly.
Step 9: Add the Component to a Page
- Edit a page in your Liferay instance — for this example, one with a grid containing three columns works well.
- Open the widget/fragment panel and locate Weather Card under your Client Extensions category.
- Drag it into each of the three columns.
- Select each instance and set its
cityconfiguration to a different value — for example, Honolulu, Fairbanks, and New York. - Publish the page.
Each instance renders independently, pulling live weather for its configured city, all from the same deployed bundle.
Step 10: Verify Cleanup Is Working
To confirm disconnectedCallback is actually doing its job:
- Open the browser DevTools → Console.
- Navigate to the page containing your Weather Card instances.
- Navigate away to a different page within the same site (using in-portal navigation, not a full browser reload).
- Navigate back.
- Confirm there are no console warnings about React state updates on unmounted components, and no obvious memory growth if you check the Memory tab across repeated navigations.
If you temporarily remove the disconnectedCallback method and repeat this test, you should be able to observe the difference — React root instances accumulating with each navigation cycle instead of being cleaned up.
Common Mistakes
1. Forgetting to Mark Dependencies as External
If react, react-dom, and @clayui/* aren't listed in rollupOptions.external, Vite bundles them directly into your component. This works, but defeats the purpose of using Liferay's shared React runtime — you'll end up with your own copy of React shipping alongside whatever the platform already provides.
2. Mismatched htmlElementName and customElements.define()
htmlElementName: weather-card
// Incorrect — Liferay won't be able to render the element correctly
customElements.define('weathercard', WeatherCard);
// Correct — matches exactly
customElements.define('weather-card', WeatherCard);
3. Missing disconnectedCallback
As covered above — the single most common defect in generated or quickly-written custom element code, and the one most likely to only surface after real usage in a navigating portal.
4. Setting instanceable: false on a Component Meant to Repeat
If you intend to place multiple copies of the same component on one page (as with three Weather Cards), instanceable must be true. Left at false, only one instance is permitted per page.
5. Forgetting the base Path in Vite Config
Without a correctly set base in vite.config.js, the built asset references can point to the wrong location once deployed, causing the component to fail to load its own JavaScript or CSS after deployment even though the build succeeded locally.
What Gets Created Behind the Scenes
| Component | Result |
|---|---|
| Deployable package | Zip archive containing the built web component bundle |
| Registration | Custom element appears in the widget/fragment panel under Client Extensions |
| Runtime dependencies | React, ReactDOM, and Clay resolved via Liferay's JS import maps, not bundled |
| Lifecycle | connectedCallback mounts the React root on insert; disconnectedCallback unmounts it on removal |
| Instances | Each placed instance runs independently, configured via its own attributes |
Summary
In this tutorial, you:
- Scaffolded a Vite + React project inside a Liferay Workspace client extension folder
- Pinned React to Liferay's bundled version to match the runtime you'll actually deploy against
- Added Clay UI components for consistent styling
- Configured Vite to externalize React, ReactDOM, and Clay rather than bundling them
- Walked through three iterations of the Weather Card component, ending with a version that correctly implements
connectedCallbackanddisconnectedCallback - Registered the component as a custom element
- Configured
client-extension.yaml, including pairing it with ajsImportMapsEntryfor easier consumption elsewhere - Built, deployed, and placed three independently configured instances on a page
- Verified proper cleanup behavior across page navigations
The lesson worth carrying forward: a custom element isn't just a React component — it's a full web component with its own lifecycle, and skipping disconnectedCallback is the kind of omission that looks completely fine in a five-minute test and only becomes a problem after real users navigate your portal for a while.
References
This tutorial is based on the following official Liferay Learn documentation and a Liferay video walkthrough:
- Creating Your First Custom Element — Liferay video walkthrough
- Creating a Basic Custom Element — Liferay Learn
- Custom Element YAML Configuration Reference — Liferay Learn
- Mastering Liferay Frontend Client Extensions: Understanding Custom Element Client Extensions — Course module, Liferay Learn
- JavaScript Import Map Entry YAML Configuration Reference — Liferay Learn
Related Reading
- Sharing JavaScript Libraries Across Pages: jsImportMapsEntry — the shared-React pattern used by this custom element.
- Creating Your First Client Extension: A Step-by-Step Tutorial (globalJS) — the simpler script-injection extension type this tutorial builds beyond.
- themeCSS Deep Dive: How Liferay's Layer Cake Styling Model Works — how Clay and Bootstrap layer together, for the styling this component builds on.
- Browse all Liferay tools — generators and reference utilities for Liferay developers.
