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 byapplyBindings(data, element), or compiled from a mustache template bycompile().
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.
/**
* 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.
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.
/**
* 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))
};
}<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
/**
* 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.
/**
* 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.
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.
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.
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.
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.
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.
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.
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.
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.