DEV Community

Nicholas Frush
Nicholas Frush

Posted on • Edited on

Web Components: An Introspective

Introduction

Web Components is a specification that provides for a set of APIs that allow creation of re-usable, framework agnostic components with style encapsulation. The goal is to be able to provide a move away from locking into a single framework, so that when another framework comes along there isn't a herculean effort to re-write everything. It runs against the grain of "write this in Angular today, rewrite this in React 3-5 years from now". More importantly, I think web-components force you to think about how to correctly build a re-usable component and prefer composition over inheritance.

Moreover, there's no guessing about how to style a button to look the same across applications (or how to inject CSS to tweak a component in an existing component library that's popular in your framework of choice). You can definitively craft elements which are tailored to the look/feel of your project with the desired functionality without breaking the bank or looking suspiciously like the component library everyone else is using.

A basic component

For my examples, I'm going to choose a relatively new framework called "Atomico". Atomico is a purpose built micro-library that's sole goal is to provide the functionality to build web-components. It's codebase is relatively small and understandable and the experience it very close to one would experience writing in React today.

I always like to provide a "button" as an example component, because I think it demonstrates a lot of concepts:

  • Property passing
  • Reflected properties
  • Closure passing
  • State changes

The button I'm going to be building will have 3 properties:

  • Disabled (boolean) - indicates whether the button is disabled or not
  • Type (string enum) - indicates what type of button we're displaying (e.g. text, outlined, normal, etc.)
  • onClick (function) - the closure we should run on handling functions.

This component in Atomico may look something like:

import { c, css, Props } from "atomico";
import tailwindcss from "../tailwindcss.css";
import {
  base as baseStyle,
  full as fullStyle,
  contained as containedStyle,
  dropdown as dropdownStyle,
  text as textStyle,
  outlined as outlinedStyle,
} from "./styles";
import classNames from "classnames/index";

export function button({
  type,
  disabled,
  onClick,
}: Props<typeof button>) {
  return (
    <host shadowDom>
      <button
        onclick={onClick}
        disabled={disabled}
        type="button"
        class={classNames(
          baseStyle,
          fullStyle,
          type == "contained" ? containedStyle : null,
          type == "text" ? textStyle : null,
          type == "outlined" ? outlinedStyle : null
        )}
      >
        <slot name="pre" />
        <slot></slot>
        <slot name="post" />
      </button>
    </host>
  );
}

button.props = {
  type: {
    type: String,
    value: "contained",
  },
  disabled: {
    type: Boolean,
    reflect: true,
    value: false,
  },
  onClick: {
    type: Function,
  },
};

button.styles = [tailwindcss];

export const Button = c(button);

customElements.define("my-button", Button);
Enter fullscreen mode Exit fullscreen mode

You'll notice, we have a simple declaration of our properties and a relatively normal looking piece of JSX.

You may have noticed the use of "slot" elements. These elements allow us to slot other elements/content into the spaces where they are when we use our component (this will be important later). For example, I could use the button like:

<my-button>Hello</my-button>
Enter fullscreen mode Exit fullscreen mode

Where "Hello" would be slotted into the middle slot.
If I wanted to put an icon before the text in my button, I could do:

<my-button><i slot="pre" class="my-cool-icon"/>Hi</my-button>
Enter fullscreen mode Exit fullscreen mode

It's important to note, named slots require the slotting element to declare what slot they go to, while unnamed slots will take any undeclared slotted child. More importantly, there can only be one unnamed slot.

Handling functions

As we saw previously, I passed the closure of a function down using the onClick property. This works because JavaScript closures include the context of their execution. For example, a closure such as:

let myOnClick = () => { this.store.update(5) }
Enter fullscreen mode Exit fullscreen mode

maintains the references to the state around it (i.e. this.store) despite getting passed down to a child.

There's also another way to handle events in web-components - Custom Events. Instead of passing a closure down, one would declare a Custom Event and fire it upward from the child when an action takes place (e.g. click), like so:

...
const dispatchEvent = useEvent("my-click", {
  bubbles: true,
  composed: true
})
...
<host shadowDom>
      <button
        onclick={() => dispatchEvent()}
Enter fullscreen mode Exit fullscreen mode

Constructing more complex components

Most people constructing more complex components coming from React will argue for high-order components and slots do exactly that. I should make a distinction - high order components work in React by providing "slots" (e.g. props.children) to compose complex components instead of throwing a bunch of components statically together in a single large component.

Slots - as explained previously - allow us to slot any element into a predefined space. You can - of course - get reference to the slot and filter what elements are allowed to appear there (but I'll leave that for another article for now or an exercise to the reader). Let's assume I have 2 elements - a my-card element that is an encapsulating card and a my-input element that encapsulates an input box.

If I wanted to make a Login form, I could easily compose something like:

<my-card>
  <my-input placeholder="Email />
  <my-input placeholder="Password />
</my-card>
Enter fullscreen mode Exit fullscreen mode

In React HOC, you may see something similar like:

function myCard = (props) => {
  ...
  return (
    <div className="...>
      {props.children}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

It's important to note, you'll rarely see this in React:

function myLoginForm = (props) => {
  ...
  return (
    <div className="...>
      <input .../>
      <input .../>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Why? What happens when requirements change? It's much easier ensure the functionality of the HOC than to go back to a singular component and re-add a new requirement (e.g. password link). The same is true for web-components. You want your basic building blocks to be static and be modular and rearrangable in any way, shape, or form. Maintaining "one off" complex components can lead to tech debt down the line and become very hard for newer developers to come on board and understand how to build a new component fast that can stand the tests of time for new requirements.

Passing objects/arrays

It's pretty common in other frameworks to be able to pass objects down as properties to components. I'd argue with the atomic nature of web-components and the use of slots, you should avoid passing an object at all costs. Let me explain:

You have a component that takes an object and assigns the the properties to child components in your framework:

function myComplexObjPass = (props) => {
  return (
    <div>
      <p>{props.myObj.a}</p>
      <p>{props.myObj.b}</p>
      <p>{props.myObj.c}</p>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

In web-components, you could achieve the same functionality (without passing the object), like:

function myWebComponent = (props) => {
  return (
    <div>
      <slot></slot>
    </div>
  )
}

...

<my-web-component>
  <p>{myObj.a}</p>
  <p>{myObj.b}</p>
  <p>{myObj.c}</p>
</my-web-component>
Enter fullscreen mode Exit fullscreen mode

In fact, I'd argue you have very little need to pass an object. If your passing an object, you like have broken your component down to atomic needs or are using slots incorrectly (whether this in web-components or a framework like React that provides props.children is irrelevant). You should always prefer to pass primitive types (e.g. String, Number) and functions and prefer for your wrapping framework to provide the "orchestration" of your web-components.

Closing Remarks

As I publish this, I'm open-sourcing Seam's web-component library today. It's far from complete - I still have styles I want to tweak and components I want to add as Seam continues to grow and change as a beloved side project of mine. But, I want to code out there that demonstrates how I have achieved complex functionality with Atomico and web-components in a very short amount of time. You can find seam-web-components here.

Top comments (0)