DEV Community

Tom Shaw
Tom Shaw

Posted on

Wiring components to server-rendered pages with domwire

domwire is a DOM-driven, on-demand component loader for plain JavaScript and TypeScript applications. It initializes ES6 classes from data-component attributes in your markup, manages their lifecycle, and lazy-loads their code only when the matching element actually exists on the page. It has zero runtime dependencies and weighs about 2 KB minified.

This tutorial covers why the library exists, the problem it solves, how the approach works, and how to use every part of the API.


1. The problem

Most web applications are not single-page apps. Server-rendered sites — Rails, Laravel, Django, WordPress, static sites — still need JavaScript behavior: a date picker here, a carousel there, an autocomplete on one form out of fifty pages. The question every one of these codebases has to answer is: how does a piece of JavaScript find out whether the page it's running on needs it?

The answer you find in most codebases, including ones written by experienced developers, looks like this:

// app.js — runs on every page
import UserCard from "./components/UserCard.js";
import Carousel from "./components/Carousel.js";
import DatePicker from "./components/DatePicker.js";
import Autocomplete from "./components/Autocomplete.js";
// ...30 more imports

document.addEventListener("DOMContentLoaded", () => {
    const userCard = document.querySelector(".user-card");
    if (userCard) new UserCard(userCard);

    const carousel = document.querySelector(".carousel");
    if (carousel) new Carousel(carousel);

    document.querySelectorAll(".date-picker").forEach((el) => {
        new DatePicker(el);
    });

    const search = document.querySelector("#search");
    if (search) new Autocomplete(search, { minChars: 3 });

    // ...30 more of these
});
Enter fullscreen mode Exit fullscreen mode

Sometimes it's dressed up as an array of { selector, klass } pairs with a loop over it, but it's the same pattern. Every component in the entire application interrogates every page: "am I needed here?"

Why this is fundamentally awkward

The direction of the question is inverted. The server already knows exactly which components a page needs — it rendered the markup. But that knowledge is thrown away, and the client re-derives it by checking every selector the application has ever defined against the current document. The page should declare what it needs; instead, every script asks.

Every page pays for every component. All 34 imports above are in the bundle whether the current page uses zero of them or all of them. The blog index downloads, parses, and executes the checkout form's validation logic. Code splitting is technically possible, but the manual pattern gives you no natural seam to split along.

The wiring file becomes a bottleneck. Every new component means editing the same central file: add an import, add a selector check. The file grows monotonically, merge conflicts concentrate there, and nothing ever gets removed because nobody is sure which pages still use which selector.

Configuration gets smuggled through ad-hoc channels. One component reads data-min-chars, another reads a global, another parses a class name like carousel--speed-3. There is no convention because the pattern doesn't provide one.

Dynamically inserted markup silently does nothing. The selector checks run once at DOMContentLoaded. Content that arrives later — an htmx swap, a fetched partial, an infinite-scroll page — contains markup that looks right but is inert, because the check already ran. The usual fixes (re-running the whole init function, framework-specific re-init events) are fragile and re-initialize things that were already alive.

Teardown doesn't exist. When a node holding a component is removed, its event listeners on window or document, its timers, and its observers keep running. The manual pattern has no place where destruction could even happen.

None of these are exotic edge cases. They are the default failure modes of the pattern, and they show up in almost every non-framework codebase of meaningful size.

2. The idea behind domwire

domwire inverts the question. Instead of every component asking the page "do you need me?", the page states what it needs:

<div data-component="user-card" data-options='{"id": 42}'></div>
Enter fullscreen mode Exit fullscreen mode

and a single manager walks the DOM once, looks up each declared name in a registry, loads the matching class, and instantiates it on that element:

import { ComponentManager } from "domwire";

const manager = new ComponentManager({
    registry: {
        UserCard: () => import("./components/UserCard.js"),
        Carousel: () => import("./components/Carousel.js"),
        DatePicker: () => import("./components/DatePicker.js"),
    },
});

await manager.boot();
Enter fullscreen mode Exit fullscreen mode

That's the entire wiring for the whole application. Three things changed structurally:

  1. The markup is the manifest. The server-side template that renders the element also declares its behavior, in the same place. The knowledge the server had is no longer discarded.
  2. The registry holds importers, not classes. () => import(...) is a function that hasn't run yet. A page with no user-card element never downloads UserCard.js. The bundler sees the dynamic import and splits each component into its own chunk automatically.
  3. There is exactly one query. One querySelectorAll("[data-component]") per boot, instead of N selector checks for N components.

3. Getting started

Install

npm install domwire
Enter fullscreen mode Exit fullscreen mode

Write a component

A component is a class extending AbstractComponent. It receives the element it was declared on and the parsed options:

// components/UserCard.js
import { AbstractComponent } from "domwire";

export default class UserCard extends AbstractComponent {
    mounted() {
        this.el.textContent = `User ${this.options.id}`;
    }

    destroy() {
        // remove listeners, cancel timers, etc.
    }
}
Enter fullscreen mode Exit fullscreen mode

this.el is the HTMLElement carrying the data-component attribute. this.options is the object parsed from data-options. The component must be the module's default export — that is what the loader unwraps.

Declare it in markup

<div data-component="user-card" data-options='{"id": 42}'></div>
Enter fullscreen mode Exit fullscreen mode

The attribute value is kebab-case; the registry key is its PascalCase form (user-cardUserCard). This keeps the HTML idiomatic (HTML is case-insensitive, so PascalCase attributes are unreliable) while the registry keys match your class and file names.

Boot

import { ComponentManager } from "domwire";

const manager = new ComponentManager({
    registry: {
        UserCard: () => import("./components/UserCard.js"),
    },
});

await manager.boot();
Enter fullscreen mode Exit fullscreen mode

boot() queries the document for [data-component], and for each match: parses the options, loads the class through the registry, instantiates it, and runs the init lifecycle. All elements initialize concurrently — boot() resolves when every component on the page is mounted.

You can boot a subtree instead of the whole document by passing a root: manager.boot(someContainer).

4. Passing options

Options travel as JSON in data-options:

<div
    data-component="autocomplete"
    data-options='{"minChars": 3, "endpoint": "/api/search", "limit": 10}'
></div>
Enter fullscreen mode Exit fullscreen mode
export default class Autocomplete extends AbstractComponent {
    mounted() {
        const { minChars = 2, endpoint, limit = 5 } = this.options;
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

This replaces every ad-hoc configuration channel — per-attribute reads, globals, encoded class names — with one convention. Server templates produce it trivially:

<div data-component="autocomplete"
     data-options="<%= { minChars: 3, endpoint: search_path }.to_json %>"></div>
Enter fullscreen mode Exit fullscreen mode

Invalid JSON does not break the page: domwire logs a warning and the component receives {}.

In TypeScript, options are typed via the generic parameter:

interface AutocompleteOptions {
    minChars?: number;
    endpoint: string;
    limit?: number;
    [key: string]: unknown;
}

export default class Autocomplete extends AbstractComponent<AutocompleteOptions> {
    mounted() {
        this.options.endpoint; // string — typed
    }
}
Enter fullscreen mode Exit fullscreen mode

5. The lifecycle

Each instance runs through six hooks. On initialization:

beforeCreate → created → beforeMount → mounted
Enter fullscreen mode Exit fullscreen mode

and on teardown:

beforeDestroy → destroy
Enter fullscreen mode Exit fullscreen mode

All hooks are optional — implement only the ones you need. In practice, mounted is where setup belongs (listeners, initial render, fetches) and destroy is where cleanup belongs:

export default class Dropdown extends AbstractComponent {
    mounted() {
        this.onDocClick = (e) => {
            if (!this.el.contains(e.target)) this.close();
        };
        document.addEventListener("click", this.onDocClick);
    }

    destroy() {
        document.removeEventListener("click", this.onDocClick);
    }
}
Enter fullscreen mode Exit fullscreen mode

The teardown hooks are what the manual pattern structurally lacks. Because the manager tracks every instance in a Map<HTMLElement, AbstractComponent>, it can tear components down deterministically — either when you call destroyComponent(el) / destroyAll(), or automatically when observed nodes leave the DOM (next section).

6. Dynamic content: observe()

Pages that mutate after load — htmx or Turbo swaps, fetched partials, modals injected on demand, infinite scroll — are where the manual pattern breaks down hardest. domwire handles them with a MutationObserver:

await manager.boot();
manager.observe();
Enter fullscreen mode Exit fullscreen mode

From that point:

  • Any inserted node matching [data-component] (or containing matches in its subtree) is initialized automatically, through the same registry, options parsing, and lifecycle as boot().
  • Any removed node holding an instance (or containing instances) gets beforeDestroy → destroy and is dropped from the instance map.

This means a server can return a fragment like

<div data-component="chart" data-options='{"series": [3, 5, 8]}'></div>
Enter fullscreen mode Exit fullscreen mode

from any endpoint, a library like htmx can swap it in, and it simply works — no re-init call, no custom events, no coupling between the swapping mechanism and the component system. Call manager.unobserve() to stop watching.

The destroy side is just as important: components removed by a swap release their listeners and timers instead of leaking. The combination — declarative init plus automatic teardown — is what makes long-lived, partially-updating pages safe.

7. Lazy loading in depth

This is the part that changes your bundle profile, so it deserves a closer look.

A registry entry is an importer: a function returning a promise of a module with a default export.

registry: {
    UserCard: () => import("./components/UserCard.js"),
}
Enter fullscreen mode Exit fullscreen mode

Three properties fall out of this shape:

Code splitting is automatic. Bundlers (Vite, webpack, Rollup, esbuild) treat each dynamic import() as a split point. Each component becomes its own chunk without any bundler configuration. Your entry bundle contains domwire (~2 KB) plus the registry — a map of names to thin importer functions.

Loading is demand-driven by the DOM itself. The importer only runs when an element declaring that component exists. The cost model becomes exact: a page pays for precisely the components it renders, nothing more. A heavy chart library behind a chart component costs zero bytes on every page without a chart.

Eager loading remains available where it's the right call. For a tiny component used on every page, skip the network round-trip:

import SiteNav from "./components/SiteNav.js";

registry: {
    SiteNav: () => Promise.resolve({ default: SiteNav }), // eager, in main bundle
    Chart:   () => import("./components/Chart.js"),       // lazy, own chunk
}
Enter fullscreen mode Exit fullscreen mode

The registry is also the single audit point the manual pattern never had: one object that lists every component the application can instantiate. Components missing from it trigger the onMissing callback rather than failing silently.

8. Namespaces

Larger applications can scope registry keys by passing namespace in the options:

<div data-component="card" data-options='{"namespace": "admin"}'></div>
Enter fullscreen mode Exit fullscreen mode

resolves to the registry key admin/Card:

registry: {
    "admin/Card": () => import("./admin/components/Card.js"),
    "Card":       () => import("./components/Card.js"),
}
Enter fullscreen mode Exit fullscreen mode

This lets independent sections of an application (or separately-owned bundles) use the same component names without collision.

9. Error handling

Two callbacks cover the failure paths:

new ComponentManager({
    registry,
    onMissing: (name, el) => {
        // a data-component name with no registry entry
        reportToMonitoring(`unknown component: ${name}`);
    },
    onError: (name, err, el) => {
        // the component constructor threw
        reportToMonitoring(err);
    },
});
Enter fullscreen mode Exit fullscreen mode

The defaults log to the console. A failing component never takes down the boot — every element initializes independently, so one broken widget leaves the rest of the page functional. This isolation is another thing the manual pattern lacks: in a single linear init function, one thrown error aborts everything after it unless every block is individually wrapped in try/catch.

10. Accessing instances and interop

After initialization, the instance is reachable two ways:

manager.instances.get(el); // from the manager's Map
el._component;             // directly from the element
Enter fullscreen mode Exit fullscreen mode

The el._component back-reference makes the system interoperable with anything else that can find a DOM element — event handlers, other scripts, the browser console ($0._component while inspecting), and Alpine.js.

Alpine.js integration

If you use Alpine for light templating alongside domwire components:

import { registerAlpineMagic } from "domwire/alpine";
registerAlpineMagic();
Enter fullscreen mode Exit fullscreen mode

This registers a $component magic that resolves to the nearest [data-component] ancestor's instance:

<div data-component="cart">
    <button x-data @click="$component.addItem(7)">Add</button>
</div>
Enter fullscreen mode Exit fullscreen mode

Alpine handles the inline reactivity; the domwire class holds the real logic and state.

11. Why this approach

It is worth being precise about why this design holds up against the alternatives.

Against the manual selector-check pattern

Every deficiency listed in section 1 maps to a structural property of domwire:

Manual pattern domwire
Every component checks every page The page declares its components; one DOM query total
All component code in every bundle Importers load per-component, per-page, on demand
Central wiring file edited for every component Markup is the wiring; the registry is a flat declarative map
Ad-hoc configuration channels One convention: JSON in data-options
Dynamic content is inert observe() initializes and destroys automatically
No teardown anywhere Tracked instances, lifecycle hooks, automatic destroy on removal
One error aborts the init sequence Per-element isolation with onError/onMissing

The key conceptual shift is direction: control flows from the document to the code. The document is already the one artifact that accurately describes the page — the server built it. Treating it as the source of truth removes an entire category of synchronization problems, because there is nothing to keep in sync.

Against adopting a framework

React, Vue, or Svelte solve this problem too — but by owning the DOM. For a server-rendered application, that means either rewriting rendering in the framework or maintaining hydration boundaries, build integration, and a client-side rendering model the application didn't need. domwire's scope is deliberately narrow: it connects classes to existing server-rendered elements. It doesn't render, doesn't manage state, doesn't own the DOM. Your components are plain ES6 classes that do ordinary DOM work, and the entire abstraction is small enough to read in one sitting (~230 lines of source).

Against Web Components / custom elements

Custom elements (<user-card>) offer browser-native lifecycle and are a reasonable alternative. The trade-offs that favor data-component attributes:

  • Behavior attaches to existing semantic elements — a <form>, a <table>, a <nav> — without wrapping them in a new tag or fighting CSS expectations around unknown elements.
  • Lazy loading must be hand-built around customElements.define (you need a separate lazy-definition mechanism); in domwire it is the default shape of the registry.
  • Options pass as one typed JSON object rather than string attributes parsed one at a time.
  • No shadow DOM implications, no constructor restrictions (custom element constructors can't touch attributes or children), no upgrade-timing subtleties.

Custom elements shine for distributable, encapsulated widgets. For wiring application behavior onto server-rendered pages, the attribute-plus-registry model is simpler and more direct.

Against jQuery-style plugins

$(".date-picker").datepicker() is the manual pattern with different syntax — the selector checks, the all-pages bundle, the missing teardown, and the inert dynamic content all carry over unchanged.

The cost

Honest accounting: you take on one attribute convention in your markup, a registry file, and a ~2 KB runtime. Component code itself is unchanged — they are classes that receive an element, which is what you would have written anyway. There is no compiler, no build-step requirement beyond what your bundler already does, and no lock-in: removing domwire means replacing boot() with the manual wiring it eliminated.

12. API reference

ComponentManager

new ComponentManager({
    selector?: string;     // default "[data-component]"
    registry?: ComponentRegistry;
    loader?: ComponentLoader;   // supply your own resolution strategy
    onMissing?: (name: string, el: HTMLElement) => void;
    onError?: (name: string, err: unknown, el: HTMLElement) => void;
})
Enter fullscreen mode Exit fullscreen mode
Member Description
boot(root?) Initialize all matching elements under root (default: document). Resolves when all are done.
observe(root?) Watch for added/removed matching nodes; init and destroy automatically.
unobserve() Stop watching.
initializeComponent(el) Initialize a single element manually.
destroyComponent(el) Run teardown hooks and forget the instance.
destroyAll() Tear down every tracked instance.
instances Map<HTMLElement, AbstractComponent> of live instances.

AbstractComponent<TOptions, TElement>

Member Description
el The host element.
options Parsed data-options object.
beforeCreate / created / beforeMount / mounted Init hooks, called in that order.
beforeDestroy / destroy Teardown hooks.

ComponentLoader

new ComponentLoader(registry) — resolves kebab-case names (optionally namespaced) to constructors via the registry. Returns null for unknown names or failed imports. Used internally by ComponentManager; injectable for custom resolution.

registerAlpineMagic(options?)

From domwire/alpine. Registers an Alpine magic (default name component, default selector [data-component]) resolving to the nearest instance.


13. Summary

The selector-check init file is one of the most common pieces of accidental architecture in web development: every component asking every page whether it's needed, every page shipping every component, and nothing handling content that arrives late or leaves early. domwire replaces it by letting the markup — the one artifact that already knows what the page contains — drive initialization. One attribute, one registry, one boot call; lazy loading, lifecycle, dynamic-content handling, and error isolation follow from the structure rather than from discipline.

Top comments (0)