What it is, and why it exists

A reactivity graph, twelve kinds of binding, and a deliberate refusal to be a framework.

domma-reactive is a small JavaScript library that keeps HTML in step with your data. It has two halves, and they are useful separately:

  • A reactivity graph. observable, observableArray, computed, effect. No DOM required - it runs in Node or a worker just as happily as in a page.
  • A binding layer. Twelve kinds of data-* attribute, activated on markup that already exists by applyBindings(data, element), or compiled from a mustache template by compile().

Version 1.2.0: about 21 KB gzipped, no dependencies, 996 tests, MIT. Every demo on this page is the real thing, running here.

The graph

An observable is one cell of state, read and written through .value. A computed is a function whose inputs are discovered at runtime - it recalculates when something it actually read changes, and sits still when anything else does. An effect is the same machinery pointed at a side effect instead of a value.

0

Doubled
0
Parity
even
Effect runs
1

Nothing else on this page re-rendered. Three bindings read count; three bindings ran.

The counter, in fullview whole file
/**
 * Counter - `observable`, `computed`, `effect`, and what "fine-grained" means.
 *
 * Three bindings read `count`. Pressing +1 re-runs those three and nothing
 * else on the page, because each binding owns its own effect and each effect
 * knows exactly what it read.
 */
function counter() {
    const count = observable(0);
    const runs = observable(0);

    // A computed works out its own inputs at runtime. Neither of these is told
    // that it depends on `count`; both discover it by reading it.
    const doubled = computed(() => count.value * 2);
    const parity = computed(() => (count.value % 2 === 0 ? 'even' : 'odd'));

    // An effect is the same machinery pointed at a side effect. `peek()` reads
    // without subscribing - reading `runs.value` here would make this effect
    // depend on its own write and spin forever.
    effect(() => {
        count.value;
        runs.value = runs.peek() + 1;
    });

    return {
        count, doubled, parity, runs,
        step(by) { count.value += by; },
        reset() { count.value = 0; }
    };
}

Two things in there are the whole model. doubled is never told that it depends on count; it finds out by reading it. And the effect reads runs through peek() rather than .value, because reading it normally would make the effect depend on its own write - the one loop the graph cannot untangle for you.

.value, everywhere, on purpose

There is no unwrapping magic. An observable is read and written through .value in JavaScript and in a template alike, so the two can never disagree about what a name means:

const count = observable(0);
count.value = count.value + 1;
<p data-bind-text="count.value"></p>
<input data-model="count.value">

The twelve bindings

Five come from mustache syntax, when you compile a template: {{name}}, class="{{cls}}", {{#if x}}, {{{html}}} and {{#each xs key=id}}. Seven are attributes, because events, two-way binding and focus all need a reference to a DOM element that survives rendering:

Attribute Purpose Example
data-bind-<name> one-way to a property, class, style or attribute data-bind-text="user.name"
data-on-<event> any DOM event data-on-click="save"
data-model two-way, control ↔ data data-model="query.value"
data-if in the DOM, or not in it data-if="isOpen"
data-options populate a <select>, and keep the selection data-options="cities"
data-focus two-way, value ↔ focus data-focus="editing"
data-component render a registered component data-component="'contact-card'"

Every value on the right is an expression, not just a path.

Contact by

The view model, live

{}

Caret in the name field: false

Thank you. Nothing is actually sent.

Text, number, radio group, checkbox and a <select> filled by data-options all write straight back into the view model. There is no change handler anywhere in this - the JSON panel is a computed reading the same observables the inputs write to, so it cannot fall out of step with them. data-focus is the other direction: tab into the name field and the boolean underneath it goes true.

The view model behind itview whole file
/**
 * Two-way binding, with no change handler anywhere.
 *
 * Text, number, radio group, checkbox and a `<select>` populated by
 * `data-options` all write straight back into the view model; `data-focus`
 * reports where the caret is. The JSON panel is a `computed` reading the same
 * observables the inputs write to, so it cannot disagree with them.
 */
function bindings() {
    const person = {
        name: observable('Ada Lovelace'),
        age: observable(36),
        town: observable('Harrogate'),
        contact: observable('email'),
        newsletter: observable(false)
    };

    const focused = observable(false);

    return {
        person,
        focused,
        towns: ['Harrogate', 'Knaresborough', 'Ripon', 'Wetherby', 'Otley', 'Ilkley'],

        asJson: computed(() => JSON.stringify({
            name: person.name.value,
            age: person.age.value,
            town: person.town.value || null,
            contact: person.contact.value,
            newsletter: person.newsletter.value
        }, null, 2))
    };
}
And the markup, exactly as the page renders itview whole file
<div class="demo-stage demo-split">
    <div class="demo-form">
        <label class="form-label">Name
            <input class="form-input" data-model="person.name.value" data-focus="focused.value">
        </label>

        <label class="form-label">Age
            <input class="form-input" type="number" min="0" max="120" data-model="person.age.value">
        </label>

        <label class="form-label">Home town
            <select class="form-select" data-model="person.town.value" data-options="towns" data-options-caption="'Anywhere'"></select>
        </label>

        <fieldset class="demo-fieldset">
            <legend>Contact by</legend>
            <label><input type="radio" name="contact" value="email" data-model="person.contact.value"> Email</label>
            <label><input type="radio" name="contact" value="phone" data-model="person.contact.value"> Phone</label>
            <label><input type="radio" name="contact" value="post" data-model="person.contact.value"> Post</label>
        </fieldset>

        <label class="demo-switch">
            <input type="checkbox" data-model="person.newsletter.value"> Send me the newsletter
        </label>
    </div>

    <div class="demo-mirror">
        <p class="demo-mirror-title">The view model, live</p>
        <pre class="demo-json" data-bind-text="asJson.value">{}</pre>
        <p class="demo-note">Caret in the name field: <b data-bind-text="focused.value">false</b></p>
        <p class="demo-note" data-if="person.newsletter.value">Thank you. Nothing is actually sent.</p>
    </div>
</div>

Lists keep their DOM

data-each="items key=id" reconciles by key. An item that stays in the collection keeps its actual DOM nodes across any change to the list, so focus, half-typed input, scroll position, CSS transitions and media playback all survive.

Type into the box below, tick a box, then delete a row above it. The row you were working on does not flinch.

Nothing to do. Add something above.

0 of 0 left

apps/basics.js - the listview whole file
/**
 * The smallest list app anyone actually writes: add, tick off, delete, with a
 * derived summary and an empty state.
 *
 * `done: observable(false)` is the line worth staring at. `observableArray`
 * tracks the array - pushes, removes, reorders - not the fields inside its
 * items. A plain `done: false` would tick in the DOM and change nothing else.
 */
function todo() {
    const todos = observableArray([
        {id: 1, title: 'Read the twelve bindings', done: observable(true)},
        {id: 2, title: 'Bind a form with no change handler', done: observable(false)},
        {id: 3, title: 'Delete a row and watch the others keep their DOM', done: observable(false)}
    ]);

    const draft = observable('');
    const composing = observable(false);
    let nextId = 4;

    return {
        todos,
        draft,
        composing,

        ready: computed(() => draft.value.trim() !== ''),

        summary: computed(() => {
            const all = todos.value;
            const left = all.filter(t => !t.done.value).length;
            return `${left} of ${all.length} left`;
        }),

        add() {
            if (draft.value.trim() === '') return;
            todos.push({id: nextId++, title: draft.value.trim(), done: observable(false)});
            draft.value = '';
            composing.value = true;      // put the caret back in the field
        },

        // Inside a list `$data` is the item, so a row reaches the list that
        // owns it through `$parent` - `data-on-click="$parent.remove($data)"`.
        remove(item) { todos.remove(item); }
    };
}

The line worth staring at is done: observable(false). observableArray tracks the array - pushes, removes, reorders - not the fields inside its items. A plain done: false would tick in the DOM and change nothing else. It is the most common early surprise, and it is the price of there being no magic anywhere else.

key= is not optional. Without it, applyBindings refuses the block and says so, because an unkeyed list cannot promise any of the above.

Components, and slots

A component is a piece of markup with its own private state and its own teardown. data-component takes an expression, so pointing it at an observable makes the rendered component follow the value - which is how a great many applications route.

This line is written in the page, projected into the component's footer slot - and it survives the swap, because it is the same DOM moved rather than markup re-rendered.

Three things are happening there. The promotion button writes to an observable the page owns, and both components see it, because params pass by reference when the expression names the observable rather than its .value. Swapping the component disposes the old instance - view model dispose() first, then its effects, then its nodes - and builds the new one. And the footer line is written in the page, projected into the component's slot: it is not rebuilt by the swap, because it is the same DOM, moved.

Both components, and the page's view modelview whole file
/**
 * Two components over one host element, and a slot the page fills.
 *
 * `data-component` takes an expression: point it at an observable and the
 * rendered component follows the value. The old instance is disposed - view
 * model first, then its effects, then its nodes - and the new one is built
 * with the params re-evaluated. The projected slot content is not rebuilt; it
 * is the same DOM, moved.
 */
registerComponent('team-card', {
    template: `
        <article class="demo-card">
            <h4 data-bind-text="$data.heading"></h4>
            <p class="demo-card-name">{{person.name.value}}</p>
            <p class="demo-card-role">{{person.title.value}}, since {{person.since.value}}</p>
            <button class="btn btn-sm" data-on-click="celebrate" data-bind-text="cheer.value"></button>
            <footer>{{#slot footer}}<small>No footer was supplied.</small>{{/slot}}</footer>
        </article>`,

    create(params) {
        // `cheer` belongs to this instance. Swap the component away and back and
        // it starts again from scratch, because the instance is a new one.
        const cheer = observable('Say something nice');
        return {
            heading: params.heading,
            person: params.person,
            cheer,
            celebrate() { cheer.value = `Well done, ${params.person.name.value}`; }
        };
    }
});

registerComponent('stat-card', {
    template: `
        <article class="demo-card demo-card--stat">
            <h4 data-bind-text="$data.heading"></h4>
            <dl class="demo-facts">
                <div><dt>Role</dt><dd>{{person.title.value}}</dd></div>
                <div><dt>Since</dt><dd>{{person.since.value}}</dd></div>
                <div><dt>Years</dt><dd>{{years.value}}</dd></div>
            </dl>
            <footer>{{#slot footer}}<small>No footer was supplied.</small>{{/slot}}</footer>
        </article>`,

    create(params) {
        return {
            heading: params.heading,
            person: params.person,
            years: computed(() => 2026 - Number(params.person.since.value))
        };
    }
});

function componentDemo() {
    // Params pass by reference when the expression names the observable, so
    // both components see this promotion - the page and the component are
    // reading the same cell, not two copies of a value.
    const person = {
        name: observable('Ada Lovelace'),
        title: observable('Analyst'),
        since: observable(2019)
    };

    const view = observable('team-card');
    const ranks = ['Analyst', 'Senior Analyst', 'Principal', 'Head of Numbers'];

    return {
        person,
        view,
        show(name) { view.value = name; },
        promote() {
            const next = ranks.indexOf(person.title.value) + 1;
            person.title.value = ranks[Math.min(next, ranks.length - 1)];
        }
    };
}

Two ways in

For HTML that already exists - server-rendered, hand-written, whatever. It activates the binding attributes in place and leaves the markup otherwise as it found it.

const handle = applyBindings({title: 'Live', rows}, document.querySelector('#app'));
handle.dispose();   // drops every effect, listener and marker it created

This is how every demo on this site works: Domma CMS renders the page, and one module per page activates it. There is no build step and no second source of truth for the markup.

It deliberately does not interpolate {{ }} in DOM that already exists, and says so once if it finds a token that looks like one. Either the server rendered the value - in which case the token is gone - or it emitted the raw token, in which case the page was broken until JavaScript ran, which is the thing server rendering exists to avoid.

For markup you own as a string. You get {{ }}, {{#if}}, {{#each}}, {{{raw}}} and {{#slot}} as well as the attributes.

const controller = compile('<p>Hello {{name}}</p>', {name: 'Ada'}, host, undefined, {reactive: true});
controller.updateAll({name: 'Grace'});   // only the text node is touched

Component templates are compiled, which is why the templates in the listing above can use mustache while the page markup cannot.

For markup with no element to spare - a run of <li>s, three <td>s, a fragment inside a <p>:

<ul>
    <li>Always shown</li>
    <!-- dm if: showExtras -->
        <li>Only when showExtras</li>
    <!-- /dm -->
</ul>

Knockout's <!-- ko if: x -->, and the one thing applyBindings genuinely could not otherwise express. Both forms are live on the tutorial.

The expression language

Binding values are parsed by hand rather than compiled with the Function constructor.

What it supports

Paths (a.b.c, a[0], a[key]), string/number/boolean/null literals, + - * / %, === !== < <= > >=, && || !, ternaries, unary - + !, calls to registered helpers, and the context names $data, $root, $parent, $parents, $parentContext, $index, $length, $component.

Precedence is JavaScript's.

What it refuses

Assignment. new. Method calls - user.toUpperCase() does not work, and neither does alert(1). Loose equality, ??, regular expressions, template literals. (Plain object and array literals do parse, since 1.2.)

Most are recognised specifically so they can be refused with a message saying what to do instead. Anything more complicated belongs in a computed, not in a template.

The one exception is data-on-*, where a call is allowed - $parent.remove($data) - because an event fires on a gesture, outside every effect, rather than during a read.

Why reach for this

There is a gap between "a page with a bit of jQuery in it" and "a single-page application". Most work lives in that gap: a server-rendered page that needs a search box that filters as you type, a form that validates itself, a list that updates when something is added. Reaching for React there means adopting a build step, a component model and a rendering strategy to solve a problem that is really about two things - knowing what changed, and saying where it goes.

No build step

A <script> tag on a page your server already rendered is enough. No compiler, no JSX, no transform, and nothing that has to run before your HTML means what it says.

Import it from a bundler if you have one. It does not care.

Server rendering stays intact

applyBindings() activates the markup that is already there. It does not re-render the page, does not replace your nodes, and does not need a matching client-side copy of the markup to hydrate against.

Your CMS renders the page. This makes it move.

It runs where eval is banned

Because expressions are parsed rather than compiled, the whole library works under script-src 'self' with no unsafe-eval - asserted in the test suite and against all three built bundles.

Knockout cannot make that claim.

Updates are fine-grained

Every binding owns its own effect, and every effect knows exactly what it read. Change one value and precisely the bindings that depend on it re-run - not a component, not a subtree, not a diff of the whole page.

There is no virtual DOM because there is nothing to diff.

Lists keep their identity

key= makes a list reconcile rather than re-render, so the row you are working in survives everything happening around it.

That is the difference between a list that works and a list that fights you.

Failure is never fatal

A binding whose expression will not parse logs one warning naming the expression and the template, and is skipped. Everything else on the page keeps working.

Nothing in the binding layer throws on bad input. One typo cannot blank a page.

What it costs

About 21 KB gzipped, no dependencies - roughly the size of the polyfills most framework setups ship before any of their own code arrives. There is no runtime to boot, no scheduler to configure and no devtools extension to install.

Bundle ~21 KB gzipped, no dependencies
Build step None required
Content Security Policy script-src 'self', no unsafe-eval
Rendering model Fine-grained bindings; no virtual DOM
Server-rendered HTML Activated in place, never rewritten
Tests 996, including every listing in the tutorial
Licence MIT

Coming from Knockout

The concepts map closely; the spellings do not, and the differences are deliberate.

Knockout domma-reactive
ko.observable(1), read o(), write o(2) observable(1), read o.value, write o.value = 2
ko.computed(fn) computed(fn) - always lazy
text: name data-bind-text="name"
css: {on: isActive} data-bind-class="isActive && 'on'"
value: / textInput: data-model="query"
hasFocus: editing data-focus="editing"
foreach: rows data-each="rows key=id"
component: {name: 'x', params: {a: b}} data-component="'x'" data-param-a="b"
<!-- ko if: x --> <!-- dm if: x -->
ko.applyBindings(vm, el) applyBindings(vm, el)

Three differences are worth knowing before you start. Reads are properties, not calls - which is what lets a template read an observable at all, since the expression language refuses method calls. key= is how lists reconcile, and it is required rather than inferred. And no unsafe-eval - which is also why there are no object literals in a binding, and why style and options are spelled with companion attributes instead of {…}.

The Knockout gap list is empty: components, slots and $parents[n] have all shipped. What remains different is spelling, and that is settled rather than pending.

What it isn't, and when not to use it

It is not a framework. There is no router, no lifecycle hooks beyond a component's dispose(), no virtual DOM, no devtools and no SSR hydration protocol beyond applyBindings. It is the layer beneath those - and sometimes that is the wrong layer to be standing on.

Then you want a framework. Those are real features and this deliberately does not have them.

If the server is only sending an empty <div id="root">, the argument that server rendering stays intact buys you nothing, and you will miss the tooling. Use React, Vue or Svelte and be happy.

A component exposing values that its own slot content can read - Vue's v-slot - genuinely meets the compile-once wall here: the content would have to be compiled against a context that does not exist until mount. Knockout has no equivalent either, but if you need it, you need something else.

The cost of a library is not only its bytes. If everyone around you reaches for useState without thinking, a different mental model in one corner of the codebase is a tax, not a saving.

Where it came from

domma-reactive is the reactive core of Domma, extracted and published on its own. That is not a marketing detail - it is why the API looks the way it does. It was built to make a content-managed, server-rendered page move: the sort of page where the HTML arrives complete, the data behind it lives in a CMS collection, and the job is to keep one in step with the other.

That is exactly what this site is. Every page here is Markdown in Domma CMS. Every room, booking and property listing is a CMS collection published at /api/v1/<slug> by a line of schema rather than a line of code. Domma JS supplies the HTTP client, the toasts and the dates. And domma-reactive is the part that makes it live.

Build something with it Read the reference Get it