Skip to content

Async Component Loading

Load web component HTML files on demand by fetching their content, parsing it into a DocumentFragment, and injecting templates, styles, and scripts into the document. This reduces initial page weight by deferring component loading until needed.

When to use

Use async loading to defer component initialization until the component is needed. It works well for multi-page apps where different pages use different components and for prototyping without a build step — just fetch and inject.

The pattern

Loading a component

Import loadComponent and pass the URL of a component HTML file. It fetches, parses, and injects the component's template, styles, and script into the document so the custom element is available immediately.

html
<script type="module">
  import { loadComponent } from "./wck.js";
  await loadComponent("./my-counter.component.html");
</script>

<my-counter></my-counter>

Component file structure

Each component bundles its template, styles, and class definition in a single .html file.

html
<!-- my-counter.component.html -->
<template name="my-counter">
  <div class="counter">
    <button on:click="data.count--">-</button>
    <span>{{ data.count }}</span>
    <button on:click="data.count++">+</button>
  </div>
</template>

<style>
  .counter {
    display: flex;
    gap: 1rem;
    align-items: center;
  }
</style>

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

The loader implementation

loadComponent fetches an HTML file, parses it into a fragment, then distributes its contents: <template> elements go to document.body, <style> elements go to document.head, and <script> elements are re-created as new <script type="module"> nodes because cloned script nodes do not execute.

javascript
export async function fetchText(url) {
  const response = await fetch(url);
  if (response.ok) {
    return response.text();
  }
  throw new Error(
    `Failed to fetch:\n`
    + `  URL:    ${url}\n`
    + `  Status: ${response.status} ${response.statusText}`
  );
}

export function parseToFragment(htmlString) {
  const fragment = new DocumentFragment();
  const temp = document.createElement("div");
  temp.innerHTML = htmlString;
  while (temp.firstChild) {
    fragment.appendChild(temp.firstChild);
  }
  return fragment;
}

export async function loadComponent(url) {
  const html = await fetchText(url);
  const fragment = parseToFragment(html);
  
  // Templates go to body
  for (const tpl of fragment.querySelectorAll("template")) {
    document.body.appendChild(tpl);
  }
  
  // Styles go to head
  for (const style of fragment.querySelectorAll("style")) {
    document.head.appendChild(style);
  }
  
  // Scripts must be re-created to execute
  const pending = [];
  for (const script of fragment.querySelectorAll("script")) {
    if (script.type === "module" || !script.type) {
      const fresh = document.createElement("script");
      fresh.type = "module";
      fresh.textContent = script.textContent;
      pending.push(new Promise((resolve) => {
        fresh.onload = resolve;
        fresh.onerror = resolve;
        document.body.appendChild(fresh);
        // Inline module scripts don't fire load events
        setTimeout(resolve, 0);
      }));
    }
  }
  
  await Promise.all(pending);
}

Trade-offs

  • No build step required. Components are plain HTML files loaded at runtime — ideal for prototyping and small apps.
  • Script re-creation. Cloned <script> elements do not execute, so the loader creates fresh elements. This means inline module scripts run but do not fire load events; the setTimeout(resolve, 0) workaround handles that.
  • No shadow DOM. Templates, styles, and scripts inject into the light DOM (body and head), keeping everything in a single flat document scope. This simplifies styling but offers no encapsulation.
  • Network cost per component. Each loadComponent call makes a fetch request. For many components, consider bundling or using HTTP/2 multiplexing.