@badcafe/elementizer
v1.0.1
Published
Convert React components to Web components
Readme
Elementizer
React to Web Component
@badcafe/elementizer is a JavaScript library that exposes any React component as a Web component.
Out-of-the-box :
- ✅ Automatic attribute conversion
- ✅ Automatic event handling
- ✅ Support of child nodes
- ✅ Support of React contexts
For more complex cases :
- ✅ Custom attribute mappers
- ✅ Custom event mapper
- ✅ Custom context filling
- ✅ Custom rendering
Setup
npm install @badcafe/elementizerUsage:
- for the most common usage, just go on reading
- for more complete and complex usage, visit our Elementizer demo, with a custom
render()method. - see also the full API
The following examples use TypeScript, but they work the same way with JavaScript.
Basic use
Import your React component and pass it to createElement() with the tag name :
foo.ts
import { createElement } from '@badcafe/elementizer';
import { Foo } from '@example/Foo'; // your React component
const FooElement = createElement({
name: 'exemple-foo',
reactComponent: Foo
})Now, let's use <exemple-foo> (and other custom elements) like any other HTML element:
<body>
<h1>Elementizer exemple</h1>
<exemple-foo>
Nested content <b>works</b>
<exemple-bar>out of the box !</exemple-bar>
</exemple-foo>
</body>Note that bundling your Web component may vary according to the tool you used (and this is outside of the scope of this documentation), but it should end with such an import in your code:
<script type="module"> import './foo' </script>Visit our Elementizer demo for a live example.
Attributes
To observe attribute changes, just pass the list of their names when creating the element:
foo.ts
import { createElement } from '@badcafe/elementizer';
import { Foo } from '@example/Foo'; // your React component
const FooElement = createElement({
name: 'exemple-foo',
reactComponent: Foo,
attributes: [
'size',
'variant'
]
})- the
on[event]attributes MUST NOT be set here (see below) - do not listen to the
idattribute (see below) - by default, before passing them to the React component, the value is parsed with
JSON.parse(); if it fails, aStringmapper is used
<body>
<h1>Elementizer exemple</h1>
<exemple-foo variant="neutral" size="20" id="myFoo">
Nested content <b id="nested">works</b>
</exemple-foo>
<script>
setInterval(() => {
const Size = ['20', '30', '40'];
const size = Size[Math.floor(Math.random() * Size.length)];
// the size will be parsed and set as a number to the React prop
myFoo.setAttribute('size', size);
}, 2000);
setInterval(() => {
// the variant was initialized with the value "neutral",
// (<exemple-foo variant="neutral" ...)
// which is an invalid JSON value, and falls back to a string
const Variant = ['positive' | 'informative' | 'negative'];
const variant = Variant[Math.floor(Math.random() * Variant.length)];
nested.textContent = variant;
// the variant will be set as a string to the React prop
myFoo.setAttribute('variant', variant);
}, 3000);
</script>
</body>Attribute mappers
Attribute values are strings, whereas React props may be... anything else.
We assume that the Foo React component expects the size prop as a number, and that the variant prop can have only the given values; we also introduce a disable boolean attribute mapped to the isDisabled React prop:
foo.ts
import { createElement } from '@badcafe/elementizer';
import { Foo } from '@example/Foo';
const FooElement = createElement({
name: 'exemple-foo',
reactComponent: Foo,
attributes: [
['size', Number],
['variant', ['positive' | 'informative' | 'negative']],
['disabled', Boolean, 'isDisabled']
]
})A mapper can be:
- one of the following constructors:
Boolean,Number,String,Date,Object,Array - an enumeration of all possible values
- a RegExp
- a custom function that takes the attribute node as an argument and returns anything useful for the React component
<body>
<h1>Elementizer exemple</h1>
<exemple-foo disabled size="20" id="myFoo">
Nested content
</exemple-foo>
<script>
setTimeout(() => {
// will check that the value is part of the list of known values
myFoo.setAttribute('variant', 'informative');
// will also delete the isDisabled React prop
myFoo.removeAttribute('disabled');
// will be converted to a number before passed to the React prop
myFoo.setAttribute('size', '30');
}, 1000);
setTimeout(() => {
// set again the disabled attribute
// see https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes#boolean_attributes
myFoo.setAttribute('disabled', '');
// the third parameter will be set directly to the React prop
// without using the mapper of this attribute
myFoo.setAttribute('size', '40', 40);
// a value that can't be mapped is set to null
myFoo.setAttribute('variant', 'fuzzy');
}, 2000);
</script>
</body>Identifier attribute
As shown in the previous examples, every custom element may have an id attribute
- the
idattribute MUST NOT be listed in the attributes list - This is because a special mapper already exists for the
idattribute - Identifiers MUST be unique in the DOM; this is why it is not set as-is in the React component
- Instead, the
idprop of the React component is derived from the Web componentidattribute - The derived value is by default:
myId→myId-elementizer(this may be customized in the API)
Standard events attributes
They work out of the box:
- Function invocation
<body>
<h1>Elementizer exemple</h1>
<exemple-foo
variant="neutral"
size="20"
onclick="doClick(event)"
>
Nested content <b id="nested">works</b>
</exemple-foo>
<script>
function doClick(e) {
const Variant = ['positive' | 'informative' | 'negative'];
const variant = Variant[Math.floor(Math.random() * Variant.length)];
nested.textContent = variant;
e.currentTarget.setAttribute('variant', variant);
}
</script>
</body>- Function call with
this
Using this is like in any other JavaScript code:
<body>
<h1>Elementizer exemple</h1>
<exemple-foo
variant="neutral"
size="20"
onclick="doClick.call(this, event)"
>
Nested content <b id="nested">works</b>
</exemple-foo>
<script>
function doClick(e) {
const Variant = ['positive' | 'informative' | 'negative'];
const variant = Variant[Math.floor(Math.random() * Variant.length)];
nested.textContent = variant;
this.setAttribute('variant', variant);
}
</script>
</body>Standard event members
<body>
<h1>Elementizer exemple</h1>
<exemple-foo
variant="neutral"
size="20"
id="myFoo"
>
Nested content <b id="nested">works</b>
</exemple-foo>
<script>
myFoo.onclick = function doClick(e) {
const Variant = ['positive' | 'informative' | 'negative'];
const variant = Variant[Math.floor(Math.random() * Variant.length)];
nested.textContent = variant;
this.setAttribute('variant', variant);
}
</script>
</body>Standard event listener
<body>
<h1>Elementizer exemple</h1>
<exemple-foo
variant="neutral"
size="20"
id="myFoo"
>
Nested content <b id="nested">works</b>
</exemple-foo>
<script>
const fooClickListener = myFoo.addEventListener('click', function doClick(e) {
const Variant = ['positive' | 'informative' | 'negative'];
const variant = Variant[Math.floor(Math.random() * Variant.length)];
nested.textContent = variant;
this.setAttribute('variant', variant);
});
</script>
</body>React events
On the React side, a component may manage a specific event, e.g. onPress(). In that case it works like any other standard event on the Web component side: the onpress attribute defines a function that will be invoked by the React component, and that function is exposed as a method of the Web component.
<body>
<h1>Elementizer exemple</h1>
<exemple-foo
variant="neutral"
size="20"
onpress="doPress(event)"
>
Nested content <b id="nested">works</b>
</exemple-foo>
<script>
function doPress(e) {
const Variant = ['positive' | 'informative' | 'negative'];
const variant = Variant[Math.floor(Math.random() * Variant.length)];
nested.textContent = variant;
e.currentTarget.setAttribute('variant', variant);
}
</script>
</body>Non-standard event handlers can only be registered as HTML attributes.
Events mappers
More often than not, events just work, but in some situations, a specific event mapper builder may be passed to createElement().
Here is a template to use when the React prop name differs from the default name mapping (the third character being uppercase):
foo.ts
import { createElement } from '@badcafe/elementizer';
import { Foo } from '@example/Foo'; // this is your React element
const FooElement = createElement({
name: 'exemple-foo',
reactComponent: Foo,
attributes: [
['size', Number],
['variant', ['positive' | 'informative' | 'negative']],
['disabled', Boolean, 'isDisabled']
],
eventMappers: {
onselectionchange: (attr) => function onSelectionChange(event) {
// this is how every event is invoked by default
attr.ownerElement[attr.name]?.call(attr.ownerElement, event);
}
}
})In fact, there is a specific default event mapper for the click event, which intercepts the bubble-phase event to prevent some events from running twice. It is defined in HTMLReactElement.defaultClickEventMapper.
Context filling
A React context is not a component, but it can be exposed as a Web component, allowing population of some data within the HTML page:
<body>
<h1>Elementizer exemple</h1>
<exemple-foo-context id="exemple">
<script id="fooContext" type="application/json">
{
"apiUrl": "https://api.exemple.com",
"theme": "dark",
"features": ["login", "search", "profile"]
}
</script>
<div>
<exemple-foo>Can use the React context</exemple-foo>
</div>
</exemple-foo-context>
</body>Notice that to render a React context, we have to change the file extension:
foo.tsx
import { createElement, Slot } from '@badcafe/elementizer';
import { FooContext } from '@example/Foo'; // your React context
const FooContextElement = createElement({
name: 'exemple-foo-context',
render() {
const fooContext = JSON.parse(document.getElementById('fooContext')?.textContent);
return (
<FooContext value={ fooContext }>
// ensure that the children will inherit the context
<Slot children={ this.original } portals={ this.reactPortals }/>
</FooContext>
);
}
})Above, the Web component wraps a static React context, but we can also have a live context:
Dynamic context
In the following example, we are just binding a React context to an observable property set on the Web component: every change will be propagated to the React component that observes it:
foo.tsx
import { createElement, Slot } from '@badcafe/elementizer';
import { FooContext } from '@example/Foo'; // your React context
export const FooContextElement = createElement({
name: 'exemple-foo-context',
initialize() {
const fooContext = JSON.parse(document.getElementById('fooContext')!.textContent);
this.observablesProps.fooContext = fooContext;
// expose as a prop of the Web component
this.fooContext = this.observablesProps.fooContext;
},
render() {
return (
<FooContext value={ this.observablesProps.fooContext }>
<Slot children={ this.original } portals={ this.reactPortals }/>
</FooContext>
);
}
});Now, from the ID of <exemple-foo-context> :
<script>
function setTheme(theme) {
exemple.fooContext.theme = 'light';
}
</script>Note: any observer React component that use this context will rerender.
Subordinate elements
In every component, Elementizer allows the use of any children, even other Web components.
However, in certain circumstances:
- The React component's design doesn't deal directly with child nodes,
- Subordinate components are pulled directly by React
- There is no 1-to-1 mapping between the React component and the Web component
- The React component works with props that are React nodes
Since we are bypassing the standard rendering process, we must supply a render() method instead when calling the createElement() function.
In all the mentioned cases, or if the rendering doesn't work as expected, or if the CSS doesn't apply as expected, you are falling into complex use cases. The Elementizer demo shows a real complex example and contains detailed explanations.
How Elementizer works
Unlike a React app where the React tree is superposed to the DOM tree, Elementizer manages a React tree separated from the DOM tree, which makes possible the cohabitation of React with non-React components; the link between the 2 worlds is made with portals and observers:
- The React tree is made exclusively of React portals
- React portals are rendered in the HTML tree
- Observers let React components re-render when their Web component wrapper is updated
However, on the Web components side, the rendering of the nested element must cohabit with the rendering of the React portals. To achieve this cohabitation, Elementizer introduces an intermediate <slot> element under the rendered React component where the original child nodes of the Web component are moved.
This may lead to CSS mismatch, for example when a React component is supposed to have another React element as its direct child. It may be necessary to inspect the rendering and either fix the original CSS or supply an add-on CSS.
Visit the Elementizer demo to see a real complex example with detailed explanations.
Things that may not work
It's worth mentioning that React and Web components are strongly incompatible, and every difference is subject to making things go wrong:
- In React, the lifecycle is managed by the React rendering engine (Virtual DOM + reconciliation). Developers do not control when DOM updates occur.
- In Web components, the lifecycle is managed directly by the browser's DOM. Lifecycle triggers are actual DOM operations: element added to or removed from the DOM, attribute change. No virtual DOM exists.
| Lifecycle Stage | React | Web Components |
| --------------- | --------------------| -------------------------- |
| Creation | function init | constructor |
| Mount | useEffect([]) | connectedCallback |
| Update | useEffect(deps) | attributeChangedCallback |
| Unmount | cleanup function | disconnectedCallback |
| DOM management | Virtual DOM diffing | Direct DOM manipulation |
| Architectural Differences | React | Web Components | | ------------------------- | -------------------------------------------------- | --------------------------------------------- | | Bootstrap | Creation → Mount | HTMLElement → HTMLReactElement (*) → Mount | | Hooks | State → render() → Virtual DOM → Diff → DOM update | DOM insertion/removal → lifecycle callbacks | | Forms management | controlled/uncontrolled | uncontrolled inputs | | State management | Contexts, component states | HTML, DOM data-[attributes], class properties |
Note (*) :
- Web components are instantiated in
importorder; this may lead to rendering issues<script>that are handling element triggered before Web component instantiation won't see aHTMLReactElement, but only aHTMLElement
API
The entry point is the createElement() function:
import { createElement } from '@badcafe/elementizer';
const MyCustomElement = createElement({
// CreateElement settings
})Create a Web component based on React.
The HTML attributes to pass to the underlying React component.
All those attributes will be also set as the static observedAttributes
property of the created class.
Events attributes MUST NOT be listed here.
HTML attributes that have to be mapped to a specific React prop may require a specific mapping function to produce a React value, and optionally a different React prop name. A mapper can also be a RegExp or a tuple of allowed values.
Exemples :
{
attributes: [
// default mapper
'foo',
// built-in mappers
['big', BigInt],
['disabled', Boolean, 'isDisabled'],
// user-defined mapper
['foo', ({value}) => value ? new Foo(value) : null],
// allowed values
['size': ['small', 'medium', 'large']],
// RegExp
['isbn', /^(?=(?:[^0-9]*[0-9]){10}(?:(?:[^0-9]*[0-9]){3})?$)[\\d-]+$/]
]
}Built-in mappers are Boolean, String, Number,
BigInt, Date, Array, Object.
The default mapper will parse the attribute value as JSON,
and in case of failure, will register a String mapper.
The class name that holds the web components ; a concrete class with that name will be created.
By default, it is derived from the name like this :
my-great-component ⮕ HTMLGreatComponentElement
By default, the custom element created is defined
in the custom element registry ; set false to
define it yourself.
Each event that requires a specific mapping function must be set here.
Exemple :
{
onselectionchange: (attr) => function onSelectionChange(event) {
attr.ownerElement[attr.name]?.call(attr.ownerElement, event);
}
}The name of the web component to create.
The given React component will be wrapped within
the Web component created, except if the render()
method is supplied.
When present, this method is executed after the constructor.
Allow to override the default rendering strategy.
Just an attribute name, or a tuple of :
- an attribute name
- a mapper for this attribute
- an optional React property name, if it is different of the attribute name
Allow to map an HTML event to a React event handler builder.
Allow to map an HTML attribute to a React prop value. Can be :
- a custom function that maps an attribute to a value
- an enumeration of the valid strings
- a regular expression
- one of the constructors
Boolean,String,Number,BigInt,Date,ObjectorArray
Allow to unmount a React component.
Looks like an attribute. Note that a mapper that do require
a real attribute may not be part of observedAttributes ;
such mapper works only on initialization.
Dispose the underlying React component.
When the underlying React component use props, those are initialized in this observable object with the attributes of the Web component wrapper.
Subsequent updates of the Web component attributes are propagated here, causing the React component refresh.
The original child nodes when the element's constructor was called ; child nodes will eventually be altered when mounting, whereas original child nodes won't.
Subordinate React components will be rendered in their own portal.
Per-attribute mappers.
Per-event mappers, that map HTML events, e.g. onclick
to a builder function that returns a React function handler.
The React function handler is supposed someway to invoke the function bound to the HTML attribute, e.g. :
attr => function (...args) {
attr.ownerElement[attr.name]?.call(attr.ownerElement, ...args);
}Propagate an attribute change in the Web component to the underlying React component.
See also: Responding to attribute changes
Mount the Web component
See also: Custom element lifecycle callbacks
Unmount the Web component
When present, this method is executed after the constructor.
This method creates the React events to inject in the underlying React component.
Called by the default render() method.
This method creates the React props to inject in the underlying React component.
Call this method to register this element, provided that this method exist, otherwise this element is already registered.
Render this Web component as a React node. This method is invoked
once. By default, it renders the React component given if any with
a child <slot> where are moved the original children, or directly
an HTML <slot>. In both cases, the React portals are also rendered.
This method may be invoked with a third parameter, that will
be set directly to this observablesProps without using the
class attribute mappers.
Attach a portal to the React tree.
The default implementation that computes the
class name of web components :
my-great-component ⮕ HTMLGreatComponentElement
Maps the HTML attribute event onclick to the React event
prop onClick: function(e) {}. This default mapper ignores the
Bubbling Phase.
The Web component and the underlying React component can't
have the same identifier ; this method allow to derive the
attribute found in the Web component to a specific one to
set on the React component, by default : myId ⮕ myId-elementizer.
Derive an attribute, e.g. onpress="doSomething()" to a method of the same name
on its host element if it doesn't exist.
See also: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes#event_handler_attributes
Maps an HTML attribute to a React prop by doing (in order) :
- looking for a specific mapper of that name
- or trying to parse as JSON the value
- or returning the value as-is
If no specific mapper is found and JSON parsing fails, then a string mapper is automatically bound to this attribute in this element for the next invokations.
Other invalid values are set to null.
Maps an HTML attribute event, e.g. onpress to a React event
prop, e.g. onPress: function onPress(e) {}.
When the React component triggers the event, the counterpart function set in the HTML attribute is invoked.
If this element has an event mapper for this attribute, its function
name will be used as the prop name, otherwise a default prop name is
derived from the attribute name (the third character being uppercased).
This implies that custom React events that doesn't follow this convention,
e.g. onSelectionChange, have to be properly mapped.
Allow a React component to render child nodes.
Internally, a <slot> element is rendered and
the given child nodes are moved within. If the
host component has subordinate React components,
the host must pass its reactPortals property
to this slot.
The child nodes to render will be moved inside this slot after it has been mounted.
If the component that renders this slot has subordinate React
components, it must pass its reactPortals property here.
