Appearance
Reactive DOM Binding
A plugin-based reactive binding system inspired by Vue. A base BindingPlugin class defines a four-method lifecycle (discover, initialize, update, cleanup). Seven plugins handle interpolation (), property (.prop), event (on:event), attribute ([attr]), conditional (@if), list (@for), and two-way (.value:input) bindings. Plugins use TreeWalker for efficient DOM traversal. Vue's reactive() and effect() power automatic re-rendering.
When to use
Use this binding system when you want Vue/Angular-like reactive web components without a framework or build step. It works well for prototyping interactive UIs with declarative template syntax.
The pattern
Template syntax
All seven binding types work together in a single template. Text uses , properties use .prop, events use on:event, attributes use [attr], conditionals use @if, lists use @for, and two-way bindings use .prop:event.
html
<template id="demo">
<div>
<h2>{{ title }}</h2>
<input
.value="searchTerm"
on:input="updateSearch(event)"
>
<ul>
<li @for="result in filteredResults">
<span [title]="result.description">
{{ result.name }}
</span>
<button
on:click="selectItem(result)"
.disabled="result.disabled"
>
Select
</button>
</li>
</ul>
<div @if="selectedItem">
Selected: {{ selectedItem.name }}
</div>
</div>
</template>Base plugin class
Every binding type extends BindingPlugin. The base class provides sensible defaults; subclasses override the methods they need.
javascript
export class BindingPlugin {
constructor(name, selector) {
this.name = name;
this.selector = selector;
}
discover(root) {
if (!this.selector) return [];
return [...root.querySelectorAll(this.selector)];
}
initialize(element, instance) {
return {};
}
update(element, instance, metadata) {
// Override in subclasses
}
cleanup(element, metadata) {
// Override in subclasses
}
}Plugin registry
Plugins register in a Map. Order matters — plugins discover elements in registration order.
javascript
export const bindingPlugins = new Map();
bindingPlugins.set(
"interpolation", new InterpolationPlugin()
);
bindingPlugins.set(
"property", new PropertyBindingPlugin()
);
bindingPlugins.set("event", new EventBindingPlugin());
bindingPlugins.set(
"attribute", new AttributeBindingPlugin()
);
bindingPlugins.set(
"conditional", new ConditionalRenderingPlugin()
);
bindingPlugins.set(
"twoway", new TwoWayBindingPlugin()
);
bindingPlugins.set("list", new ListRenderingPlugin());The seven binding types
1. Text interpolation —
Uses TreeWalker with NodeFilter.SHOW_TEXT to find text nodes that contain patterns. Splits text into static and dynamic parts, evaluates expressions in the instance context.
html
<span>{{ message }}</span>
<p>Count: {{ count * 2 }}</p>InterpolationPlugin walks the cloned template with NodeFilter.SHOW_TEXT, collects every text node containing , splits each into static and dynamic parts, and evaluates the dynamic expressions against the component instance on each update.
javascript
export class InterpolationPlugin
extends BindingPlugin {
discover(root) {
const nodes = [];
const walker = document.createTreeWalker(
root,
NodeFilter.SHOW_TEXT,
{
acceptNode: (node) =>
/\{\{.*?\}\}/.test(node.textContent)
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT,
},
);
let node;
while (node = walker.nextNode()) {
nodes.push(node);
}
return nodes;
}
update(originalNode, instance, metadata) {
metadata.parts.forEach(part => {
if (part.type === "expression") {
const func = new Function(
"instance",
`with (instance) {
return ${part.content};
}`,
);
part.node.textContent =
func(instance) ?? "";
}
});
}
}2. Property binding — .property="expression"
Sets JavaScript properties directly on elements. Uses TreeWalker with NodeFilter.SHOW_ELEMENT to find attributes starting with ..
html
<input .value="inputText">
<button .disabled="isLoading">Submit</button>3. Event binding — on:event="handler"
Attaches event listeners during initialize and removes them in cleanup. The handler expression executes in the instance's data context.
html
<button on:click="handleClick()">Click</button>
<input on:input="updateValue(event)">4. Attribute binding — [attr]="expression"
Sets or removes HTML attributes. null, undefined, and false values remove the attribute.
html
<div [class]="dynamicClass"></div>
<img [src]="imageUrl">
<button [title]="tooltipText">Hover</button>5. Conditional rendering — @if="condition"
Replaces elements with comment placeholders when the condition is falsy. Recreates the element from stored HTML and re-binds nested plugins when the condition becomes truthy.
html
<div @if="isVisible">Shown conditionally</div>
<p @if="count > 10">Count exceeds 10</p>6. List rendering — @for="item in items"
Clones a template element for each item. Creates a scoped context using Object.create(instance) so each iteration has access to its own item and $index variables plus the parent instance.
html
<ul>
<li @for="item in items">
{{ item.name }} — ${{ item.price }}
</li>
</ul>7. Two-way binding — .prop:event="model"
Combines property binding with event listening. The property updates the DOM when data changes; the event updates the data when the user interacts.
html
<input .value:input="searchQuery">
<select .value:change="selectedOption">Trade-offs
- Expression evaluation uses
with. All plugins evaluate expressions vianew Function("instance", "with (instance) { ... }"). This gives full JavaScript expression power butwithis forbidden in strict mode. The generated functions are not in strict mode, so this works — but it prevents using strict-mode-only features inside expressions. - TreeWalker over querySelectorAll. Every plugin uses
document.createTreeWalkerfor DOM traversal.InterpolationPluginusesSHOW_TEXT; all others useSHOW_ELEMENT. This is more performant for deep DOM trees but requires more code than selector-based approaches. - Progressive enhancement. The system needs no build step. Import from a CDN, write HTML templates with special attributes, and reactive bindings work immediately.
- Error resilience. Every plugin wraps expression evaluation in try/catch and falls back gracefully: placeholder text, original values, or no-op. A bad expression in one binding does not break others.
- Conditional rendering re-creates DOM.
@ifstores the originalouterHTMLand rebuilds the element from scratch when toggling visibility. This avoids stale binding state but is more expensive than hiding/showing. - List scoping via prototype chain.
Object.create(instance)creates a per-item scope that inherits from the parent. This is lightweight but means item-level mutations to inherited properties affect the parent.