Web component tutorial for React devs
Tags: webcomponents, tutorial, react, javascript
Published: 2024-04-25 02:47:16

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?

1. A custom JSX tag
2. Internal state
3. Properties and children
4. Life cycle

Let’s write some code!

> NOTE that enable to learn progressively, demo code are ignoring good practices. 
Full demos are [here](https://stackblitz.com/@JennieJi/collections/web-component-v-s-react-demos).

## Starting with a classic counter

In React, we may create a counter with very little code:

```jsx
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](https://stackblitz.com/edit/stackblitz-starters-2jknur?file=src%2Fweb-component%2FCounter.js)):

```html
<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](https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry/define#valid_custom_element_names) 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**.

![https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_shadow_DOM/shadowdom.svg](https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_shadow_DOM/shadowdom.svg)

When the `mode` of Shadow DOM is set to `open`, we can access the shadow tree from the shadow host like this:

```javascript
shadowHostElement.shadowRoot.querySelector()
```

When the `mode` of Shadow DOM is set to `closed`,  the shadow tree will get hidden from the document. 

```javascript
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:

```javascript
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](https://stackblitz.com/edit/stackblitz-starters-cjhwu5?file=src%2Findex.js)):

```html
<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:

1. This state is a `Set` - it can only act like a boolean.
2. 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:

```jsx
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](https://stackblitz.com/edit/stackblitz-starters-pbvgbt?file=public%2Findex.html)):

```html
<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 😄.

```html
<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.

```html
<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`:

1. connectedCallback
2. disconnectedCallback
3. adoptedCallback
4. attributeChangedCallback

Let’s [try this demo](https://stackblitz.com/edit/stackblitz-starters-hrjcya?file=src%2Findex.js):

```html
<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:

```bash
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:

```bash
Disconnected.
```

Clicking  `Start demo` button again, you will see following log again:

```bash
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](https://stackoverflow.com/a/51002629/3322507):

> 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](https://stackblitz.com/edit/stackblitz-starters-29shew?file=src%2Freact-app%2Findex.js)):

```jsx
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](https://stackblitz.com/edit/stackblitz-starters-29shew?file=src%2Fweb-component%2FReactAppWrapper.js)).

First, modify the React app to allow passing in the app root:

```jsx
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:

```jsx
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](https://lit.dev/), [Stencil](https://stenciljs.com/), etc. Yep! Another framework!🙈