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
});
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>
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();
That's the entire wiring for the whole application. Three things changed structurally:
- 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.
-
The registry holds importers, not classes.
() => import(...)is a function that hasn't run yet. A page with nouser-cardelement never downloadsUserCard.js. The bundler sees the dynamic import and splits each component into its own chunk automatically. -
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
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.
}
}
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>
The attribute value is kebab-case; the registry key is its PascalCase form (user-card → UserCard). 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();
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>
export default class Autocomplete extends AbstractComponent {
mounted() {
const { minChars = 2, endpoint, limit = 5 } = this.options;
// ...
}
}
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>
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
}
}
5. The lifecycle
Each instance runs through six hooks. On initialization:
beforeCreate → created → beforeMount → mounted
and on teardown:
beforeDestroy → destroy
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);
}
}
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();
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 asboot(). - Any removed node holding an instance (or containing instances) gets
beforeDestroy → destroyand 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>
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"),
}
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
}
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>
resolves to the registry key admin/Card:
registry: {
"admin/Card": () => import("./admin/components/Card.js"),
"Card": () => import("./components/Card.js"),
}
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);
},
});
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
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();
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>
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;
})
| 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)