Skip to content

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 via new Function("instance", "with (instance) { ... }"). This gives full JavaScript expression power but with is 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.createTreeWalker for DOM traversal. InterpolationPlugin uses SHOW_TEXT; all others use SHOW_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. @if stores the original outerHTML and 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.