Advanced Web Component Integration
- TypeScript for Web Component Wrappers
- Wrapping npm Packages with TypeScript
- Advanced Lit Features
- State Synchronization Patterns
- Practical Patterns
This guide covers advanced patterns for creating Web Component wrappers in Vaadin, including wrapping npm packages with TypeScript, using Lit lifecycle hooks and decorators, and managing complex state between the client and server.
For basic integration, see Integrating Web Components and Wrap a Web Component. For integrating React components, see Using React Components in Flow.
TypeScript for Web Component Wrappers
TypeScript is recommended for non-trivial Web Component wrappers. It provides:
-
Type-safe property definitions that catch mismatches at build time
-
Interfaces for configuration objects passed between Java and the client
-
Better IDE support with auto-completion when using third-party libraries
A typical TypeScript wrapper defines interfaces for any complex data structures:
Source code
TypeScript
interface ChartConfig {
type: 'bar' | 'line' | 'pie';
animate: boolean;
colors?: string[];
}
interface DataPoint {
label: string;
value: number;
}These types ensure that the objects passed from Java via setPropertyBean() conform to the expected shape, and that event data dispatched back to Java is consistent.
Wrapping npm Packages with TypeScript
Wrapping a third-party npm library involves three parts working together: the @NpmPackage annotation declares the dependency, a TypeScript file imports and wraps the library as a Web Component, and a Java class exposes the API to server-side code.
This section demonstrates the pattern using a fictional @example/widget package. The same approach applies to any npm library.
Step 1: The TypeScript Wrapper
The TypeScript file imports the npm package and wraps it in a LitElement-based Web Component:
Source code
example-widget-wrapper.ts
Key points:
-
The
@propertydecorator exposes reactive properties that Java can set viagetElement().setProperty()orsetPropertyBean(). When a property changes, Lit automatically re-renders. -
The
@statedecorator marks internal state that triggers re-renders but is not exposed as HTML attributes. -
The
configproperty usestype: Objectso that Vaadin’ssetPropertyBean()can pass a Java record directly as a JavaScript object — no manual JSON serialization needed. -
The
firstUpdatedlifecycle callback initializes the third-party widget after the component’s DOM is ready. -
The
disconnectedCallbacklifecycle callback cleans up the widget instance to prevent memory leaks when the component is removed from the DOM. -
A
CustomEventis dispatched to communicate changes back to the Java side.
Step 2: The Java Component Class
The Java class declares the npm dependency and provides a typed API:
Source code
ExampleWidget.java
Key points:
-
@NpmPackagedeclares the npm dependency. Vaadin installs it automatically during the build. -
@JsModulepoints to the.tsfile. Vaadin compiles TypeScript as part of the frontend build. -
The
WidgetConfigrecord is passed to the client viasetPropertyBean(), which serializes it to a JavaScript object automatically. For simple properties, usesetPropertydirectly. -
@DomEventand@EventDatamap the client-sideCustomEventto a typed Java event class.
Step 3: Using the Component
Source code
Java
ExampleWidget widget = new ExampleWidget();
widget.setTitle("Dashboard Widget");
widget.setConfig(new ExampleWidget.WidgetConfig("bar", true));
widget.addWidgetChangeListener(event -> {
Notification.show(event.getLabel() + ": " + event.getValue());
});
add(widget);Advanced Lit Features
When creating Web Component wrappers, Lit provides features beyond basic property binding and rendering.
Reactive Properties vs. Internal State
Use @property for values that should be settable from Java (via element properties or attributes). Use @state for internal values that affect rendering but shouldn’t be part of the public API:
Source code
TypeScript
import { LitElement, html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
@customElement('my-counter')
class MyCounter extends LitElement {
// Public: settable from Java via getElement().setProperty("max", 100)
@property({ type: Number })
max: number = 10;
// Internal: only used within this component
@state()
private _count: number = 0;
render() {
return html`
<span>${this._count} / ${this.max}</span>
<button @click=${this._increment}
?disabled=${this._count >= this.max}>+</button>
`;
}
private _increment() {
this._count++;
this.dispatchEvent(
new CustomEvent('count-changed', {
detail: { count: this._count },
bubbles: true,
})
);
}
}Both @property and @state trigger re-renders when they change. The difference is that @property values can be set via HTML attributes and are part of the component’s public API.
Lifecycle Callbacks
Lit provides several lifecycle callbacks beyond the standard Web Component ones:
| Callback | When to Use |
|---|---|
| The element is added to the DOM. Set up event listeners or start periodic tasks. Always call |
| The element is removed from the DOM. Clean up event listeners, timers, or third-party library instances to prevent memory leaks. Always call |
| Called once after the component’s first render. Use this to initialize third-party libraries that need a DOM element to attach to. |
| Called after every render. Use this to react to property changes, such as reconfiguring a wrapped library. The |
| Called before rendering. Use this to compute derived values from properties before they’re used in the template. |
Example using firstUpdated to initialize a library and disconnectedCallback to clean it up:
Source code
TypeScript
@customElement('chart-wrapper')
class ChartWrapper extends LitElement {
private _chart: Chart | null = null;
@property({ type: String })
type: string = 'bar';
override firstUpdated() {
const canvas = this.renderRoot.querySelector('canvas');
this._chart = new Chart(canvas, { type: this.type });
}
override updated(changed: PropertyValues) {
if (changed.has('type') && this._chart) {
this._chart.config.type = this.type;
this._chart.update();
}
}
override disconnectedCallback() {
super.disconnectedCallback();
this._chart?.destroy();
this._chart = null;
}
override render() {
return html`<canvas></canvas>`;
}
}Shadow DOM vs. Light DOM
By default, Lit renders into Shadow DOM, which encapsulates styles. This is usually the right choice for reusable or distributable components, where you don’t want the application’s styles to affect the component’s internals.
For project-specific components, the opposite is often true: you may want the application’s theme and global styles to apply to the component’s content. In that case, rendering into Light DOM is a reasonable choice, because it lets page styles reach inside the component. Light DOM also lets the wrapped content participate in form submission. To render into Light DOM, override createRenderRoot() to return the element itself:
Source code
TypeScript
import { LitElement, html } from 'lit';
import { customElement } from 'lit/decorators.js';
@customElement('light-dom-wrapper')
class LightDomWrapper extends LitElement {
override createRenderRoot() {
// Render into Light DOM instead of Shadow DOM
return this;
}
override render() {
return html`<div class="wrapper">Content here</div>`;
}
}|
Note
|
Light DOM trade-offs
Light DOM components have no style encapsulation: their styles can leak out, and page styles leak in. For a project-specific component meant to match the application theme, that’s exactly what you want. For a reusable component intended to look the same everywhere, it makes the component harder to maintain — prefer Shadow DOM in that case.
|
State Synchronization Patterns
Communication between the Java server and the client-side Web Component happens through element properties and events.
Simple Properties
For primitive values, set them directly from Java:
Source code
Java
getElement().setProperty("label", "Hello");
getElement().setProperty("count", 42);
getElement().setProperty("visible", true);These are accessible in the TypeScript component as reactive properties.
Complex Objects with setPropertyBean
For objects, use setPropertyBean() to pass a Java record or bean directly to the client. Vaadin handles the JSON serialization automatically:
Java side:
Source code
Java
record ChartConfig(String type, boolean animate) {}
ChartConfig config = new ChartConfig("bar", true);
getElement().setPropertyBean("config", config);TypeScript side:
Source code
TypeScript
@property({ type: Object })
config: ChartConfig = { type: 'bar', animate: false };The client receives the bean as a plain JavaScript object. No manual JSON parsing is needed.
You can also read the bean back on the server using getPropertyBean():
Source code
Java
ChartConfig config = getElement().getPropertyBean("config", ChartConfig.class);For lists and maps, use setPropertyList() and setPropertyMap(). Vaadin serializes the elements with Jackson:
Source code
Java
getElement().setPropertyList("selectedIds", List.of(1, 2, 3));
getElement().setPropertyMap("labels", Map.of("x", "Revenue", "y", "Quarter"));For cases where you need lower-level control, setPropertyJson() accepts a Jackson BaseJsonNode:
Source code
Java
ObjectNode config = JacksonUtils.createObjectNode();
config.put("type", "bar");
config.put("animate", true);
getElement().setPropertyJson("config", config);Two-Way Binding with Custom Events
To send state changes from the client back to the server, dispatch a CustomEvent and listen for it in Java:
TypeScript side:
Source code
TypeScript
this.dispatchEvent(new CustomEvent('selection-changed', {
detail: { selectedIds: [1, 2, 3] },
bubbles: true,
composed: true,
}));Java side using @DomEvent:
Event data is deserialized with Jackson, so you can bind the expression directly to a typed value such as a List<Integer>:
Source code
Java
@DomEvent("selection-changed")
public static class SelectionChangedEvent
extends ComponentEvent<MyComponent> {
private final List<Integer> selectedIds;
public SelectionChangedEvent(MyComponent source, boolean fromClient,
@EventData("event.detail.selectedIds") List<Integer> selectedIds) {
super(source, fromClient);
this.selectedIds = selectedIds;
}
public List<Integer> getSelectedIds() {
return selectedIds;
}
}Java side using addEventListener:
The low-level listener receives the raw event data as a Jackson JsonNode. Use getEventData(TypeReference) to deserialize the whole event payload into a typed object, or navigate the JsonNode manually:
Source code
Java
getElement().addEventListener("selection-changed", event -> {
JsonNode ids = event.getEventData().get("event.detail.selectedIds");
// process selection, e.g. ids.get(0).asInt()
}).addEventData("event.detail.selectedIds");Set composed: true on events that need to cross Shadow DOM boundaries.
Practical Patterns
Loading States and Async Initialization
When wrapping libraries that require async initialization (e.g., loading data from a remote source), use internal state to track loading:
Source code
TypeScript
@state()
private _loading: boolean = true;
@state()
private _error: string | null = null;
override async firstUpdated() {
try {
await this._initializeLibrary();
this._loading = false;
} catch (e) {
this._error = e instanceof Error ? e.message : 'Initialization failed';
this._loading = false;
}
}
override render() {
if (this._error) {
return html`<div class="error">${this._error}</div>`;
}
if (this._loading) {
return html`<div class="loading">Loading...</div>`;
}
return html`<div id="content"></div>`;
}Cleanup and Memory Management
Always clean up when the component is disconnected. This is critical for third-party libraries that create DOM elements, register global event listeners, or start timers:
Source code
TypeScript
private _resizeObserver: ResizeObserver | null = null;
private _refreshInterval: number | null = null;
override connectedCallback() {
super.connectedCallback();
this._resizeObserver = new ResizeObserver(() => this._handleResize());
this._resizeObserver.observe(this);
this._refreshInterval = window.setInterval(() => this._refresh(), 30000);
}
override disconnectedCallback() {
super.disconnectedCallback();
this._resizeObserver?.disconnect();
this._resizeObserver = null;
if (this._refreshInterval !== null) {
clearInterval(this._refreshInterval);
this._refreshInterval = null;
}
}Error Handling in Event Dispatch
When dispatching events with data that might fail to serialize, validate before dispatching:
Source code
TypeScript
private _notifyChange(data: unknown): void {
// Only dispatch if there's meaningful data
if (data == null) return;
this.dispatchEvent(new CustomEvent('data-changed', {
detail: data,
bubbles: true,
composed: true,
}));
}4FA5E312-9C1B-4A3E-B7D2-6A8C3F2E1D09