Create a React component from a custom element with SSR support.
Large parts of this package are copied from the
@lit/react
package.
The main difference is that this package is more geared towards the use of vanilla custom elements and support SSR in a simpler way.
Key differences are:
- Favor attributes over properties (for primitive values)
- Configurable conversion functions for properties to attributes
- Support for custom element templates via a static method
getTemplateHTML
While React can render Web Components, it cannot easily pass React props to custom element properties or event listeners.
This package provides a utility wrapper createComponent
which makes a
React component wrapper for a custom element class. The wrapper correctly
passes React props
to properties accepted by the custom element and listens
for events dispatched by the custom element.
Since React v19 there is better support for custom elements, but unfortunately React wrappers are still needed for the time being.
For properties, the wrapper inspects the web component class to discover
its available properties. Next it differentiates from the original @lit/react
.
Then any React props
passed with property names are set on the custom element
as properties if there is no accomponying attribute name listed in
the static observedAttributes
array and if the property is not found in
the base class of the custom element. Primitive values are preferred as
attributes to support SSR (server side rendering).
For events, createComponent
accepts a mapping of React event prop names
to events fired by the custom element. For example passing {onfoo: 'foo'}
means a function passed via a prop
named onfoo
will be called when the
custom element fires the foo event with the event as an argument.
Import React
, a custom element class, and createComponent
.
import * as React from 'react';
import { createComponent } from 'ce-la-react';
import { MyElement } from './my-element.js';
export const MyElementComponent = createComponent({
tagName: 'my-element',
elementClass: MyElement,
react: React,
events: {
onactivate: 'activate',
onchange: 'change',
},
});
After defining the React component, you can use it just as you would any other React component.
<MyElementComponent
active={isActive}
onactivate={(e) => (isActive = e.active)}
/>
Event callback types can be refined by type casting with EventName
. The
type cast helps createComponent
correlate typed callbacks to property names in
the event property map.
Non-casted event names will fallback to an event type of Event
.
import * as React from 'react';
import { createComponent } from 'ce-la-react';
import { MyElement } from './my-element.js';
import type { EventName } from 'ce-la-react';
export const MyElementComponent = createComponent({
tagName: 'my-element',
elementClass: MyElement,
react: React,
events: {
onClick: 'pointerdown' as EventName<PointerEvent>,
onChange: 'input',
},
});
Event callbacks will match their type cast. In the example below, a
PointerEvent
is expected in the onClick
callback.
<MyElementComponent
onClick={(e: PointerEvent) => {
console.log('DOM PointerEvent called!');
}}
onChange={(e: Event) => {
console.log(e);
}}
/>
NOTE: This type casting is not associated to any component property. Be careful to use the corresponding type dispatched or bubbled from the webcomponent. Incorrect types might result in additional properties, missing properties, or properties of the wrong type.
For advanced use cases, the props
to attributes conversion functions can be customized.
toAttributeName
- A function that converts the prop name to an attribute name. This is useful when the attribute name is a different format than the prop name. e.g. playbackId -> playback-idtoAttributeValue
- A function that converts the prop value to an attribute value. This could be useful for serializing complex objects to strings.
The default functions are:
export function defaultToAttributeName(propName: string) {
return propName.toLowerCase();
}
export function defaultToAttributeValue(propValue: unknown) {
if (typeof propValue === 'boolean') return propValue ? '' : undefined;
if (typeof propValue === 'function') return undefined;
if (typeof propValue === 'object' && propValue !== null) return undefined;
return propValue;
}
This package supports SSR by rendering the custom element template and setting
the React dangerouslySetInnerHTML
prop with the rendered template. The custom
element is then hydrated on the client side.
The only requirement is that the custom element class must provide a
static getTemplateHTML
method that returns the template HTML string. This
method is called with attributes that are converted from the React props
.
import * as React from 'react';
import { createComponent } from 'ce-la-react';
class MyProfile extends (globalThis.HTMLElement ?? class {}) {
static shadowRootOptions = { mode: 'open' };
static getTemplateHTML(attrs: Record<string, string>) {
return `<h1>Hello, ${attrs.firstname}!</h1>`;
}
static get observedAttributes() {
return ['firstname'];
}
#isInit = false;
// This init method might look strange but it is a pattern to avoid
// trying to access attributes in the constructor which is illegal!
//
// Just remember to call this method everywhere before you need to
// evaluate anything in the element's shadow DOM.
// Could be even in a property getter or setter.
#init() {
if (this.#isInit) return;
this.#isInit = true;
if (!this.shadowRoot) {
this.attachShadow({ mode: 'open' });
this.#render();
}
this.#upgradeProperty('firstName');
}
#render() {
if (this.shadowRoot) {
this.shadowRoot.innerHTML = MyProfile.getTemplateHTML({
...namedNodeMapToObject(this.attributes),
});
}
}
// This is a pattern to update property values that are set before
// the custom element is upgraded.
// https://web.dev/custom-elements-best-practices/#make-properties-lazy
#upgradeProperty(this: ElementProps<MyProfile>, prop: string) {
if (Object.prototype.hasOwnProperty.call(this, prop)) {
const value = this[prop];
// Delete the set property from this instance.
delete this[prop];
// Set the value again via the (prototype) setter on this class.
this[prop] = value;
}
}
connectedCallback() {
this.#init();
}
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
this.#init();
if (oldValue === newValue) return;
if (name === 'firstname') {
this.#render();
}
}
get firstName() {
return this.getAttribute('firstname');
}
set firstName(value) {
if (value != null) this.setAttribute('firstname', value);
else this.removeAttribute('firstname');
}
}
if (globalThis.customElements && !globalThis.customElements.get('my-profile')) {
globalThis.customElements.define('my-profile', MyProfile);
}
const MyProfileComponent = createComponent({
react: React,
tagName: 'my-profile',
elementClass: MyProfile,
});