Docs

Advanced Web Component Integration

How to wrap npm packages, use advanced Lit features, and handle complex state synchronization.

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 @property decorator exposes reactive properties that Java can set via getElement().setProperty() or setPropertyBean(). When a property changes, Lit automatically re-renders.

  • The @state decorator marks internal state that triggers re-renders but is not exposed as HTML attributes.

  • The config property uses type: Object so that Vaadin’s setPropertyBean() can pass a Java record directly as a JavaScript object — no manual JSON serialization needed.

  • The firstUpdated lifecycle callback initializes the third-party widget after the component’s DOM is ready.

  • The disconnectedCallback lifecycle callback cleans up the widget instance to prevent memory leaks when the component is removed from the DOM.

  • A CustomEvent is 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:

  • @NpmPackage declares the npm dependency. Vaadin installs it automatically during the build.

  • @JsModule points to the .ts file. Vaadin compiles TypeScript as part of the frontend build.

  • The WidgetConfig record is passed to the client via setPropertyBean(), which serializes it to a JavaScript object automatically. For simple properties, use setProperty directly.

  • @DomEvent and @EventData map the client-side CustomEvent to 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

connectedCallback()

The element is added to the DOM. Set up event listeners or start periodic tasks. Always call super.connectedCallback().

disconnectedCallback()

The element is removed from the DOM. Clean up event listeners, timers, or third-party library instances to prevent memory leaks. Always call super.disconnectedCallback().

firstUpdated(changedProperties)

Called once after the component’s first render. Use this to initialize third-party libraries that need a DOM element to attach to.

updated(changedProperties)

Called after every render. Use this to react to property changes, such as reconfiguring a wrapped library. The changedProperties map contains the previous values.

willUpdate(changedProperties)

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

Updated