Web component tutorial for React devs
Jennie
Posted on April 25, 2024
Web component was a breaking news but sank over the years. I never paid attention util one day I read about a new open-sourced micro front-end framework is based on web component š¤Æ.
Reading MDN and some beginner blogs hardly answered my doubts. Being a React dev for so many years, I knew I would learn better with a React mindset.
What is the foundation of a React component?
- A custom JSX tag
- Internal state
- Properties and children
- Life cycle
Letās write some code!
NOTE that enable to learn progressively, demo code are ignoring good practices.
Full demos are here.
Starting with a classic counter
In React, we may create a counter with very little code:
export function Counter() {
const [count, setCount] = useState(0);
const increase = useCallback(() => {
setCount((n) => n + 1);
}, []);
return (
<div>
Current count: {count}
<button onClick={increase}>Increase!</button>
</div>
);
}
The web component version is a bit longer (try here):
<my-counter></my-counter>
<script>
class Counter extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<div>
Current count: <span class="counter-number">0</span>
<button class="counter-increase">Increase!</button>
</div>
`;
let count = 0;
const countNode = this.shadowRoot.querySelector('.counter-number');
this.shadowRoot
.querySelector('.counter-increase')
.addEventListener('click', () => {
countNode.innerText = ++count;
});
}
}
customElements.define('my-counter', Counter);
</script>
To create the web component, we declared a class extends HTMLElement
first, and define a custom HTML element with customElements.define()
API that binds my-counter
HTML tag to the class.
š” Note that there are a lot of constrains with the custom HTML tag.
Next, we enriched the component with a Shadow DOM by this.attachShadow({ mode: āopenā })
, and operates the DOM from this.shadowRoot
.
Shadow DOM explained
Shadow DOM, just like the document
in iframe, is a HTML fragment that contains a DOM tree named as shadow tree. It is attached to a regular DOM element named as shadow host.
When the mode
of Shadow DOM is set to open
, we can access the shadow tree from the shadow host like this:
shadowHostElement.shadowRoot.querySelector()
When the mode
of Shadow DOM is set to closed
, the shadow tree will get hidden from the document.
console.log(shadowHostElement.shadowRoot); // null
As it is a separate HTML fragment, the styles are isolated. That means shadow DOM wonāt inherit or apply any styles from the document
, and vise versa.
To style the shadow DOM, we need to place the style tag in the shadow tree like this:
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
.my-div { background: red; }
</style>
<div class="my-div"></div>
`;
The style isolation and the shadow root access created a layer of encapsulation that is very important in sharable components and micro front-end architecture.
Must we use shadow DOM? In some cases, but not in this one! It is generally recommended to use shadow DOM in a web component to have the layer of encapsulation. One of the case that must use shadow DOM will be demonstrated later.
State in web component
In the counter demo above, we did tedious DOM operations for the count
state in our web component version. Do we have state management in web component?
The answer is yes and no. It is quite different (try demo here):
<with-state></with-state>
<script>
class WithState extends HTMLElement {
constructor() {
super();
const shadowRoot = this.attachShadow({ mode: 'open' });
shadowRoot.innerHTML = `
<div>
<input type="checkbox" id="check" />
<label for="check">
Tick and untick me!
</label>
</div>
<style>
// ONLY IN SAFARI WITH PREVIEW ON
:state(--checked) {
background: green;
}
</style>
`;
const { states } = this.attachInternals();
const label = shadowRoot.querySelector('label');
shadowRoot.querySelector('input').addEventListener('change', (e) => {
if (e.target.checked) {
states.add('--checked');
} else {
states.delete('--checked');
}
label.innerText = states.has('--checked') ? 'Untick me!' : 'Tick me!';
});
}
}
customElements.define('with-state', WithState);
</script>
In this WithState
component, we enabled states with this.attachInternals()
first. Then add or delete state accordingly.
There are a few interesting details:
- This state is a
Set
- it can only act like a boolean. - The state require a ugly double dash - if not, browser simply throws error although some of MDN document is not specifying this. Well, this is still tolerable considering to avoid conflicts with tag names in CSS.
This āstateā is kind of unhelpful. The only game changer is probably the :state()
CSS selector which is only supported in Safari with preview FF on š. This selector allows the state work similar to input disabled, focus.
Creating components with Template
Demos above attach the DOM tree to web component with innerHTML
. In fact, they can be more flexible, scalable and readable with <template/>
and <slot/>
instead.
Letās look at a simple Card component:
export function Card({ children }) {
return (
<div
style={{
background: '#ccc',
borderRadius: '8px',
padding: '14px',
}}
>
{children}
</div>
);
}
A card component in React is a container with children
prop. While in web component, this is achievable with <template/>
and <slot/>
(try here):
<my-card>
<span slot="card-content">Span slot of the card.</span>
</my-card>
<template id="card-template">
<div style="background: #ccc; border-radius: 8px; padding: 14px">
<slot name="card-content"></slot>
</div>
<style>
::slotted(span) {
background: red;
}
</style>
</template>
<script>
class Card extends HTMLElement {
constructor() {
super();
const template = document.getElementById('card-template');
const shadow = this.attachShadow({
mode: 'open',
});
shadow.appendChild(template.content.cloneNode(true));
}
}
customElements.define('my-card', Card);
</script>
The component class simply attached the cloned DOM tree from a <template/>
node. Styles and the āslotā for content noes were all specified in a template.
We use a <slot />
tag with a name
attribute to declare slot, and we may insert any tag with an attribute slot
and the name
we declared.
Although the HTML markups in <template/>
is not rendered anywhere, it is accessible and mutable like any other regular DOM elements. Hence, weād better clone the template DOM tree every time instead of using it directly.
What happens to any content out of slot? I tried with below code and the browser perfectly ignored them š.
<my-card>
Card Children
</my-card>
How about using the same slot multiple times? It works! š¤£Ā The elements were added into card-content slot from top to down.
<my-card>
<span slot="card-content">Span slot of the card.</span>
<div slot="card-content">Div slot of the card.</div>
</my-card>
Remember that we talked about shadow DOM may not be necessary, not here! It turns out the <slot />
tag only works in the web component context with shadow DOM.
The life cycle of a web component
To pass in properties and trigger effects when the property changes, we need to work with the life cycle methods.
There are 4 life cycle methods other than constructor
:
- connectedCallback
- disconnectedCallback
- adoptedCallback
- attributeChangedCallback
Letās try this demo:
<button id="with-attribute-demo-toggle">Start demo</button>
<div id="with-attribute-demo-container"></div>
<template id="with-attribute-demo">
<input
id="with-attribute-input"
type="text"
placeholder="Change value here"
/>
<with-attribute value="Initialized"></with-attribute>
</template>
<script>
class WithAttribute extends HTMLElement {
static observedAttributes = ['value'];
constructor() {
super();
console.log('Constructor.');
}
connectedCallback() {
const input = document.getElementById('with-attribute-input');
input.addEventListener('change', (e) => {
this.setAttribute('value', e.target.value);
});
console.log('Connected.');
}
disconnectedCallback() {
console.log('Disconnected.');
}
adoptedCallback() {
console.log('Adopted');
}
attributeChangedCallback(name, oldValue, newValue) {
console.log(`Attribute "${name}" changed ${oldValue} to ${newValue}.`);
}
}
customElements.define('with-attribute', WithAttribute);
const toggleBtn = document.getElementById('with-attribute-demo-toggle');
toggleBtn.addEventListener('click', () => {
const container = document.getElementById('with-attribute-demo-container');
if (container.hasChildNodes()) {
container.innerHTML = '';
toggleBtn.innerText = 'Start demo';
} else {
toggleBtn.innerText = 'End demo';
container.appendChild(
document.getElementById('with-attribute-demo').content.cloneNode(true)
);
}
});
</script>
Donāt get terrified by the length. The WIthAttribute
component only logs the life cycle methods. Other than that, there are a little input and a button to help mount, unmount the component and change the component attribute. Additionally, a compulsory static observedAttributes
is added for observing the attribute change.
Click Start demo
button, here is the console log:
Constructor.
Attribute "value" changed null to Initialized.
Connected.
Something interesting here: Right after the constructor
, the attributeChangedCallback
was triggered to change my value
attribute from undefined
to āInitializedā
that I assigned directly on the HTML markup, and connectedCallback
follows. That is suggesting connectedCallback
is actually a better place for initializing a component.
When we click the End demo
button, a new log appended:
Disconnected.
Clicking Start demo
button again, you will see following log again:
Constructor.
Attribute "value" changed null to Initialized.
Connected.
I was expecting Constructor
not showing up here but nahā¦
The last adoptedCallback
I didnāt figure out with the MDN doc. Then I looked stackoverflow for help and found this:
TheĀ
adoptedCallback(
) method is called when a Custom Element is moved from one HTML document to another one with theĀ[adoptNode()](https://developer.mozilla.org/en-US/docs/Web/API/Document/adoptNode)
Ā method.
š¤Æwhat? You can do that? #TIL.
Using web component in React
If you are maintaining a React repo but wondering whether you could introduce web component. The answer is yes! Implementing a web component into a React component is just like any other HTML markups (try here):
export function App() {
return (
<Card>
<my-counter></my-counter>
</Card>
);
}
However, placing a React component into a web component is not that feasible as the React components need to connect with the React app root.
Wrapping a React app in web component
Though React component in web component is not likely to work (and not necessary?), it is possible to encapsulate the entire React app in a web component (try here).
First, modify the React app to allow passing in the app root:
export function initApp(container = document.getElementById('react-app')) {
const root = createRoot(container);
root.render(
<StrictMode>
<App />
</StrictMode>
);
}
Next, create a web component wrapper like this to initialize the React app:
import { initApp } from '../react-app';
class ReactAppWrapper extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({ mode: 'closed' });
initApp(shadow);
}
}
customElements.define('react-app-wrapper', ReactAppWrapper);
That is exactly what micro front-end needs!
Last but not least
Nobody likes to manually update DOM when state updates with the convenience of modern frameworks, but that could be resolved by integrating another framework or library. For example, Lit, Stencil, etc. Yep! Another framework!š
Posted on April 25, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.