Writing Vanilla Web Components

The web component lifecycle in plain JavaScript, why vanilla authoring is more manual than frameworks, and the case for a lightweight library over rolling everything yourself.

June 12, 20265 min read1 / 3

When I got through the shadow DOM styling tools, I assumed the hard part was done. Then came the JavaScript.

The authoring side is where vanilla web components make their frustration clear -- and why 60-plus libraries exist to smooth it over.

The Lifecycle

Every custom element class can implement four lifecycle callbacks. The browser calls them for you:

JavaScript
class CustomAlert extends HTMLElement { static get observedAttributes() { return ['theme', 'message']; } constructor() { super(); // must be first -- wires up the HTMLElement prototype } connectedCallback() { // element is now in the DOM -- safe to render and attach events this.render(); } disconnectedCallback() { // element removed from the DOM -- clean up event listeners, timers, subscriptions } attributeChangedCallback(name, oldValue, newValue) { // one of the observed attributes changed -- re-render or respond this.render(); } } customElements.define('custom-alert', CustomAlert);

The mapping to what you already know:

  • connectedCallback is componentDidMount or useEffect(() => {}, [])
  • disconnectedCallback is componentWillUnmount or the cleanup return from useEffect
  • attributeChangedCallback is Vue's watch -- fires when a specific value changes, gives you old and new

The one non-obvious requirement: observedAttributes must explicitly list every attribute you want to watch. If an attribute is not in that array, attributeChangedCallback will never fire for it. Nothing is observed by default.

Web component lifecycle: constructor to connectedCallback to attribute updates to disconnectedCallback ExpandWeb component lifecycle: constructor to connectedCallback to attribute updates to disconnectedCallback

Constructing the Shadow DOM

The constructor is where you attach a shadow root and inject the component's initial HTML. This is where vanilla web components become verbose:

JavaScript
constructor() { super(); const template = document.createElement('template'); template.innerHTML = ` <style> :host { display: block; } .alert { padding: 1rem; border-radius: 6px; } </style> <div class="alert"><slot></slot></div> `; const shadow = this.attachShadow({ mode: 'open' }); shadow.appendChild(template.content.cloneNode(true)); }

The clone step is not optional. A template's content is a DocumentFragment. Appending it directly moves it -- the template becomes empty and breaks every subsequent instance. cloneNode(true) creates a deep copy and leaves the original intact. This is the standard pattern described in the custom elements spec.

There is a static alternative. If the template markup already lives inline in the page as <template id="my-alert">, you skip createElement entirely:

JavaScript
const shadow = this.attachShadow({ mode: 'open' }); const tpl = document.querySelector('#my-alert'); shadow.appendChild(tpl.content.cloneNode(true));

The constructor becomes just the clone-and-append steps -- no innerHTML setup required.

Either way, the pattern is: get or create template → attach shadow → clone and append. It is three or four steps where most frameworks would require one.

Why innerHTML Needs Care

Setting template.innerHTML is the fastest path to HTML in a shadow root. It also creates a security surface worth knowing about.

Any string interpolated into innerHTML is evaluated as HTML markup. That includes content from user input, external APIs, or a CMS. If sanitization is incomplete, malicious content runs as HTML -- a classic XSS vector.

For templates with no dynamic content, innerHTML is fine. For components that render user-provided or API-provided values, the safe path is a library that treats expression slots as data, not markup.

Lit's html tag template literal never evaluates expression values as HTML. The library tracks which parts of the template contain expressions, and those parts are set as text or DOM properties -- never injected as raw markup. The safety is structural, not a sanitization step you can forget to call.

The Case for a Library

At last count, over 60 distinct approaches to writing web components exist, tracked and compared at webcomponents.dev. They range from thin ergonomic wrappers to TypeScript-first frameworks with their own compilers.

All of them exist for the same reason: vanilla web components are low-level. The boilerplate is real:

  • Lifecycle callbacks are functional but every update is manual
  • Template cloning requires an explicit cloneNode(true) on each instance
  • Reactive re-renders mean calling your own render function at the right times
  • State management and event wiring are yours to design from scratch

Web component libraries are typically 5 to 10 kilobytes. For that cost you get:

  • Declarative templates that update efficiently without manual diffing
  • Automatic re-render when observed properties change
  • A more readable component lifecycle
  • TypeScript support without boilerplate

The 5-10KB is the entire library -- not an entry fee that scales with component count.

The next post goes through the main options: Lit's html template model, Hybrids' functional plain-object approach, Stencil's TypeScript decorators with multi-framework output, and how Svelte and Vue can export existing components as custom elements without a rewrite.

The Essentials

  1. Every custom element can implement four lifecycle callbacks: constructor, connectedCallback, disconnectedCallback, and attributeChangedCallback.
  2. observedAttributes must explicitly list every attribute to watch. Unlisted attributes trigger no callbacks.
  3. Two ways to source a template: document.createElement('template') with innerHTML set in JS, or document.querySelector('#id') if the markup already lives inline in the HTML. Either way, attach shadow root then cloneNode(true) and append.
  4. cloneNode(true) is required. Appending template.content directly moves the fragment -- the template empties and every subsequent instance breaks.
  5. innerHTML with interpolated values creates an XSS surface. Libraries like Lit evaluate expression slots as data, not markup, making the template safe by construction.
  6. Web component libraries are 5 to 10KB. The vanilla API is fully functional; libraries reduce the ergonomic friction.

Further Reading and Watching

Practice what you just read.

Web Component Lifecycle: Where Does This Code Go?
1 exercise