DEV Community

Jennie
Jennie

Posted on

Web component tutorial for React devs

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.

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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.

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:

shadowHostElement.shadowRoot.querySelector()
Enter fullscreen mode Exit fullscreen mode

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

console.log(shadowHostElement.shadowRoot); // null
Enter fullscreen mode Exit fullscreen mode

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>
`;
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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:

export function Card({ children }) {
  return (
    <div
      style={{
        background: '#ccc',
        borderRadius: '8px',
        padding: '14px',
      }}
    >
      {children}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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:

<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>
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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

Constructor.
Attribute "value" changed null to Initialized.
Connected.
Enter fullscreen mode Exit fullscreen mode

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>
    );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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!🙈

Top comments (1)

Collapse
 
dannyengelman profile image
Danny Engelman • Edited

Note: super() sets and returns the 'this' scope; and attachShadow sets and returns this.shadowRoot. And append() takes multiple arguments/elements all for free.

So a full blown counter component can be as easy as:

For React users: count is just a location in Computer Memory, why not use the displayed value...

jsfiddle.net/WebComponents/47ovs52f/

<my-counter count="42"></my-counter>

<script>
  customElements.define("my-counter", class extends HTMLElement {
    static get observedAttributes() {
      return ["count"]
    }

    constructor() {
      const createElement = (tag, props = {}) => Object.assign(document.createElement(tag), props);
      super()
        .attachShadow({mode:"open"})
        .append(
          createElement("style", {
            innerHTML: `b{padding:0 1em}`
          }),
          createElement("button", {
            innerHTML: "dec",
            onclick: e => this.count--
          }),
          this.counter = createElement("B", {
            innerHTML: "0"
          }),
          createElement("button", {
            innerHTML: "inc",
            onclick: e => this.count++
          })
        )
    }

    get count() {
      return Number(this.counter.textContent);
    }

    set count(newValue) {
      this.setAttribute("count", this.counter.textContent = newValue);
    }

    attributeChangedCallback(name, oldValue, newValue) {
      if (oldValue !== newValue) this.count = newValue;
    }
  });

</script>
Enter fullscreen mode Exit fullscreen mode