Skip to content

Document Bootstrap

Bootstrap a web component by cloning a <template>, discovering bindings through a plugin system, appending the content to the component element, and returning a reactive render function powered by Vue's effect(). This lifecycle draws on a five-phase model (init, descend, load, attach, detach) refined into four plugin methods: discover, initialize, update, and cleanup.

When to use

Use this pattern to bootstrap web components from <template> elements with reactive rendering. It suits custom elements that need reactive data binding and automatic re-rendering without a framework.

The pattern

Using bindTemplate

Create a template in your HTML, define a custom element class with reactive data, and call bindTemplate with the template and the element instance. It returns a render function that sets up reactive updates.

html
<template name="my-app-template">
  <div>
    <h1>{{ data.title }}</h1>
    <button on:click="data.count++">
      Clicked {{ data.count }} times
    </button>
    <input
      .value="data.name"
      on:input="data.name = event.target.value"
    >
    <p @if="data.name">Hello, {{ data.name }}!</p>
  </div>
</template>

<my-app></my-app>

<script type="module">
  import {
    bindTemplate, reactive,
  } from "@chriscalo/web-component-kit";
  
  class MyApp extends HTMLElement {
    connectedCallback() {
      this.data = reactive({
        title: "My Reactive App",
        count: 0,
        name: "",
      });
      const render = bindTemplate(
        document.querySelector(
          'template[name="my-app-template"]'
        ),
        this,
      );
      render();
    }
  }
  
  customElements.define("my-app", MyApp);
</script>

Waiting for components

Use componentsReady to wait until custom elements are defined before interacting with them.

javascript
export async function componentsReady(...names) {
  await Promise.all(
    names.map(name => customElements.whenDefined(name))
  );
}

// Usage
await componentsReady("my-app", "ui-icon");

The bindTemplate implementation

bindTemplate accepts a template (selector or element) and an instance (typically a custom element). It clones the template, runs plugin discovery, appends the content, and returns a render() function that sets up a Vue effect() to re-run updates whenever reactive data changes.

javascript
export function bindTemplate(templateSelector, instance) {
  // Resolve template
  const templateEl =
    typeof templateSelector === "string"
      ? document.querySelector(templateSelector)
      : templateSelector;
  const root = templateEl.content.cloneNode(true);
  const boundElements = new Map();
  
  // Discovery phase — each plugin finds its bindable
  // elements and initializes them
  for (const [name, plugin] of bindingPlugins) {
    const elements = plugin.discover(root);
    for (const element of elements) {
      const domEl = element.element || element;
      if (!boundElements.has(domEl)) {
        boundElements.set(domEl, []);
      }
      const metadata = plugin.initialize(element, instance);
      boundElements.get(domEl).push({
        plugin,
        metadata,
        element: domEl,
      });
    }
  }
  
  // Attach cloned content to the instance element
  instance.appendChild(root);
  
  // Return reactive render function
  let renderEffect;
  return function render() {
    if (renderEffect) {
      renderEffect.stop?.();
    }
  
    renderEffect = effect(() => {
      for (const [element, bindings] of boundElements) {
        for (const { plugin, metadata } of bindings) {
          plugin.update(element, instance, metadata);
        }
      }
    });
  };
}

Lifecycle phases

The plugin lifecycle maps to an earlier five-phase component model.

PhasePlugin methodPurpose
initdiscoverFind bindable elements in the template clone
descend(implicit)TreeWalker traversal during discover
loadinitializeSet up bindings and return metadata
attachupdateUpdate DOM when data changes
detachcleanupRemove event listeners and free resources

Trade-offs

  • Dual-purpose instance. The instance parameter serves as both the data context (expressions evaluate via with (instance)) and the DOM container (content is appended with instance.appendChild). This is concise but couples data and DOM concerns.
  • Vue reactivity dependency. The render function wraps all updates in a Vue effect(), so @vue/reactivity must be available (typically imported from a CDN). This gives automatic re-rendering but adds a runtime dependency.
  • Plugin ordering matters. Plugins are discovered in registration order. The conditional plugin skips itself when re-binding nested elements to prevent infinite recursion.
  • No virtual DOM. Updates modify the real DOM directly. For large component trees, this may be less efficient than diffing approaches, but for prototyping and small apps the simplicity is worthwhile.