Setup

From nothing installed to a page that updates itself, by every route in.

There is no build step, no compiler and no configuration file. Whichever route you take below, the finished state is the same: the library is on the page, and applyBindings() or compile() is called once.

What it needs

Browsers Anything with ES2020 - optional chaining, globalThis, Proxy. No polyfills are shipped or required
Node 18 or newer for the reactive core (observable, computed, effect, expressions, the renderer). No DOM needed
Bundler None required. If you have one, nothing to configure
TypeScript Declarations ship in the package - see Types

Install it

By npm

npm install domma-reactive
import {observable, computed, effect, applyBindings} from 'domma-reactive';   // ESM
const {observable, applyBindings} = require('domma-reactive');                // CommonJS

The package declares exports, so both resolve to the right bundle without configuration:

Condition File
import dist/domma-reactive.esm.js
require() dist/domma-reactive.cjs
<script> / the browser field dist/domma-reactive.min.js

By script tag

The UMD bundle puts every export on a global called DommaReactive:

<script src="https://cdn.jsdelivr.net/npm/domma-reactive@1/dist/domma-reactive.min.js"></script>
<script>
    const {observable, computed, applyBindings} = DommaReactive;
</script>

Pin the major version (@1) rather than floating, or pin exactly (@1.2.0) if you would rather approve every upgrade yourself.

By module, without a bundler

<script type="module">
    import {observable, applyBindings} from 'https://cdn.jsdelivr.net/npm/domma-reactive@1/+esm';
</script>

Or serve the file yourself - copy node_modules/domma-reactive/dist/domma-reactive.esm.js next to your own scripts and import it by path. That is what this site does, so that its demos and its code listings are the same file.

What arrives

File Format Size
dist/domma-reactive.min.js UMD, minified 65 KB, 21 KB gzipped
dist/domma-reactive.cjs UMD 65 KB
dist/domma-reactive.esm.js ES module, comments intact 336 KB - your bundler minifies it

No dependencies, MIT.

Your first page

The library exists for HTML that already exists. Write the relationships into the markup, then activate them once:

<div id="app">
    <input data-model="query.value" placeholder="Search">
    <p data-bind-text="summary.value"></p>

    <ul data-each="visible.value key=id">
        <li data-bind-text="name"></li>
    </ul>

    <p data-if="empty.value">Nothing matches.</p>
</div>
import {observable, observableArray, computed, applyBindings} from 'domma-reactive';

const rows = observableArray([
    {id: 1, name: 'Ada Lovelace'},
    {id: 2, name: 'Grace Hopper'}
]);

const query = observable('');

const visible = computed(() => {
    const needle = query.value.trim().toLowerCase();
    return rows.value.filter(row => row.name.toLowerCase().includes(needle));
});

const app = {
    rows,
    query,
    visible,
    summary: computed(() => `${visible.value.length} of ${rows.length}`),
    empty: computed(() => visible.value.length === 0)
};

const handle = applyBindings(app, document.querySelector('#app'));

That is a complete application. Three rules cover almost every early mistake:

  • .value everywhere. An observable is read and written through .value, in JavaScript and in a binding alike.
  • key= is required on a list. It names the property that identifies an item, and it is what lets a row keep its DOM node.
  • {{ }} is not interpolated by applyBindings. Use data-bind-text. Mustache works inside a data-each body, which is a template rather than rendered output, and inside a component's own template.

If you own the markup as a string

compile() is the same machinery pointed the other way - it renders a mustache template and binds it:

import {compile} from 'domma-reactive';

const controller = compile(
    '<ul>{{#each rows key=id}}<li>{{name}}</li>{{/each}}</ul>',
    {rows},
    document.querySelector('#out'),
    undefined,
    {reactive: true}          // one effect per binding - a standalone consumer wants this
);

{reactive: true} is off by default because Domma wires its own effects. Outside Domma, turn it on.

In a bundler

Nothing to configure. Vite, webpack, Rollup, esbuild and Parcel all resolve the package through its exports field and tree-shake the ESM build normally.

// vite.config.js - no entry for domma-reactive is needed
export default {};

Two things occasionally worth knowing:

  • The ESM bundle ships with its comments, which is why it is 336 KB on disk and about a fifth of that after your minifier has run. Do not serve it unminified in production.
  • There is no side-effectful module init, so sideEffects: false treatment is safe. Importing only observable really does leave the compiler out of your bundle.

In Node, or a worker

The reactive core needs no DOM:

import {observable, computed, effect, flushSync} from 'domma-reactive';

const price = observable(10);
const total = computed(() => price.value * 1.2);

effect(() => console.log(total.value));   // logs 12

price.value = 20;
flushSync();                              // logs 24 - no microtask wait

Only compile(), applyBindings() and the custom-binding machinery touch document, and only when called. That makes the graph usable for derived state on a server, in a queue worker, or in a test with no jsdom at all.

On a server-rendered page

This is the case the library was built for, and the pattern is three lines of infrastructure:

  1. The server renders the page, binding attributes and all.
  2. One module finds its mount point.
  3. applyBindings() activates it.
<div data-app="search">
    <input data-model="query.value">
    <p data-bind-text="summary.value"></p>
</div>

<script type="module" src="/js/boot.js"></script>
// boot.js
const APPS = {search: () => import('./apps/search.js')};

for (const element of document.querySelectorAll('[data-app]')) {
    const load = APPS[element.dataset.app];
    if (load) load().then(module => module.mount(element));
}

Nothing is re-rendered, nothing is hydrated, and the page works as static HTML until the module lands.

Inside a Domma CMS site

Two details specific to Domma CMS, both of which this site relies on:

  • data-* attributes survive the markdown sanitiser untouched, so a page author can write binding attributes directly into page content with no plugin involved.
  • HTML comments do not survive it, so virtual bindings (<!-- dm if: … -->) need markup that reaches the page after sanitisation - a plugin injection, or a transform on markdown:afterParse.

Load the library itself from a plugin's inject.bodyEnd snippet, or from public/js/, and mount as above.

Content Security Policy

There is no eval and no Function constructor anywhere in the package - asserted in the unit suite and against all three built bundles. So this is enough:

Content-Security-Policy: default-src 'self'; script-src 'self'

No unsafe-eval. No unsafe-inline, provided your own bootstrap is in a file rather than in a <script> block. If you load the bundle from a CDN, add that host to script-src.

Knockout, by comparison, compiles binding strings with new Function, which a policy like the above blocks outright. The expressions here are parsed by hand instead - including, since 1.2, plain object and array literals - see Expressions.

Tearing it down

An effect is a live node in the graph, and removing the DOM does not remove it. Every entry point hands you the means to stop it:

Created by Torn down by
effect(fn) .dispose() on the returned Computation
applyBindings(…) handle.dispose()
compile(…) controller.destroy()
observable.subscribe(fn) the returned off(), or off.dispose()

handle.dispose() drops every effect, listener, list instance and marker it created, restores a hidden data-if element, and leaves the markup as it found it. Both are safe to call twice.

On a page that lives until the tab closes this is academic. In an application that swaps views, skipping it is a leak that is invisible in the DOM.

Types

TypeScript declarations ship in the package - dist/domma-reactive.d.ts for import, dist/domma-reactive.d.cts for require(), both wired through the exports map - so there is nothing to install from @types and nothing to declare yourself:

import {observable, computed} from 'domma-reactive';

const count = observable(0);                  // Observable<number>
const doubled = computed(() => count.value * 2);

count.value = 'three';                        // compile error - a number was expected
doubled.value = 4;                            // compile error - a computed with no write is read-only

The reference lists every signature.

Upgrading

Semantic versioning: a binding spelling or an export never changes inside a major. The changelog records every release, and npm run test:dist in the repository verifies all 33 exports through require(), import() and a <script> tag before anything is published.

When it does not work

What you see What it means What to do
Nothing happens at all, no warnings applyBindings never ran, or ran against the wrong element Check the selector matched, and that the module actually loaded (network tab)
{{name}} appears literally on the page applyBindings never interpolates mustache in existing DOM data-bind-text="name"
A field is empty, with a warning naming name.value Bound the observable, not its value (before 1.2 it showed [object Object]) data-model="name.value"
data-if="show" never shows, with a warning show is the observable, not its value - since 1.2 it fails closed data-if="show.value"
A <!-- dm if --> inside a virtual list is missing from every row Virtual bindings are not read inside a list body {{#if flag}} inside the body
The list renders nothing, with a warning No key= on data-each data-each="rows.value key=id"
data-options renders no options You handed it the observable, not the array data-options="groups.value"
A binding is silently skipped Its expression did not parse Look for the one warning - it names the expression and the template
Ticking a checkbox changes nothing The field on the list item is not reactive done: observable(false), and bind done.value
An edit inside a component never reaches the page The param passed a copy data-param-contact="$data", not data-param-name="name.value"
Refused to evaluate a string as JavaScript in the console Something else on the page needs unsafe-eval Not this library - it contains no eval; check your other scripts
Effects keep running after the view is gone Nothing was disposed handle.dispose()

Nothing in the binding layer throws on bad input. Every failure above logs exactly one warning naming the expression and the template, and skips that binding alone - one typo cannot blank a page.

The reference Build something