domma-reactive is a small JavaScript library that keeps HTML in step with your data.
You write the relationship down once - data-bind-text="user.name" in your markup, or computed(() => price.value * qty.value) in your code - and it maintains it from then on. Change the data; exactly the parts of the page that depend
on it update. There is no update code to write, no re-render to trigger and no build step: a <script> tag on a page
your server already rendered is enough.
This is the reactive core of Domma, published separately so it can be used on its own.
Data changes, the page follows. Assign to count.value and every binding that read it re-runs - once, on the next
microtask, however many writes led there.
Derived values that work out their own inputs. computed(() => price.value * qty.value) discovers at runtime which
state it actually read, so it recalculates when the price changes and sits still when anything else does.
Bindings live in your HTML. data-bind-text, data-on-click, data-if, data-each -
twelve kinds, on markup your server already rendered.
applyBindings(data, element) activates them in place; it never rewrites your page.
Forms bind both ways. data-model="query" on a text input, checkbox, radio or <select multiple>
keeps the control and the data in step in both directions, with no change handler to write. data-focus
does the same for which element is focused.
Lists keep their DOM. data-each="rows key=id" reconciles by key: delete the second row and the
first one keeps its actual DOM node, so its focus, its scroll position and its half-typed input all survive.
It runs where eval is banned. Binding expressions are parsed by hand rather than compiled with the
Function constructor, so the whole library works under script-src 'self' with no unsafe-eval.
Reusable units of UI. registerComponent('contact-card', {template, create}) gives a piece of markup
its own private state and its own teardown. data-component="'contact-card'" renders one; point that at an observable
instead and the component swaps when the value changes. Slots let the page pass markup in.
One broken binding never takes the page down. A binding whose expression will not parse logs a single warning naming the expression and the template, and is skipped. Everything else keeps working.
About 21 KB gzipped, with no dependencies, MIT-licensed, and 996 tests.
It is not a framework. There is no router, no lifecycle hooks beyond a component's dispose(), no virtual DOM and no
devtools - if you want those, reach for React, Vue or Svelte. This is the layer beneath: reactivity and bindings, to drop into a page
you already have, or to build something larger on top of. Limits and non-goals lists every
omission and the reasoning for it.
New here? Tutorial.md builds a working contacts page step by step - add, edit in place, search, filter, delete and persist, in about 120 lines. Every listing in it is under test.
- Tutorial - build a contacts system, step by step
- Install · Quick start · A complete small app
- Reactive core -
observable·observableArray·computed· extenders ·effect·subscribe· Batching · Disposal - Template bindings - the twelve kinds ·
data-on-*·data-bind-*·data-model·data-if·data-options·data-focus· custom bindings · binding context - Keyed lists · Components · Slots ·
applyBindings· The renderer · Expressions - API reference · Coming from Knockout · Things that will catch you · Limits and non-goals
npm install domma-reactiveimport {observable, computed, effect} from 'domma-reactive'; // ESM
const {observable} = require('domma-reactive'); // CommonJSOr as a plain script - the UMD bundle exposes the global DommaReactive:
<script src="node_modules/domma-reactive/dist/domma-reactive.min.js"></script>
<script>
const count = DommaReactive.observable(0);
</script>| File | Format | Size |
|---|---|---|
dist/domma-reactive.min.js |
UMD, minified - browser, <script> |
65 KB, 21 KB gzipped |
dist/domma-reactive.cjs |
UMD - require() |
65 KB |
dist/domma-reactive.esm.js |
ES module, unminified - import |
336 KB (comments intact; your bundler minifies) |
TypeScript declarations ship in the package - nothing to install from @types. observable(0) is an
Observable<number>, and assigning to a read-only computed is a compile error rather than a runtime warning.
The reactive core (observable, computed, effect, expressions, contexts, the renderer) needs no DOM and runs in
Node or a worker. Only the compiler functions touch document, and only when called.
import {observable, computed, effect} from 'domma-reactive';
const price = observable(10);
const qty = observable(3);
const total = computed(() => price.value * qty.value);
effect(() => console.log('total is', total.value)); // logs "total is 30"
qty.value = 4; // logs "total is 40" on the next microtaskWire it to a page with applyBindings, which activates binding attributes on HTML that already exists:
<div id="app">
<input type="number" data-model="qty.value">
<p data-bind-text="total.value"></p>
</div>import {applyBindings} from 'domma-reactive';
applyBindings({price, qty, total}, document.querySelector('#app'));Every binding gets its own effect, so typing in the input updates the paragraph and nothing else on the page is touched.
Everything below is one working application, built from nothing but the public API. It is the shape of the smallest app
anyone actually writes: a list you add to, tick off, and delete from, with a derived summary and an empty state. This
exact code is a test in the repository (src/apply-bindings.test.js), so it is verified rather than illustrative.
<div id="app">
<input data-model="draft.value" placeholder="What needs doing?">
<button data-on-click="add()">Add</button>
<ul data-each="todos key=id">
<li>
<input type="checkbox" data-model="done.value">
<span data-bind-class="done.value && 'struck'" data-bind-text="title"></span>
<button data-on-click="$parent.remove($data)">×</button>
</li>
</ul>
<p data-if="todos.length === 0">Nothing to do.</p>
<p data-bind-text="summary.value"></p>
</div>import {applyBindings, observable, observableArray, computed} from 'domma-reactive';
const todos = observableArray([]);
const draft = observable('');
let nextId = 1;
const app = {
todos,
draft,
summary: computed(() => {
const all = todos.value;
return `${all.filter(t => !t.done.value).length} of ${all.length} left`;
}),
add() {
if (draft.value.trim() === '') return;
todos.push({id: nextId++, title: draft.value.trim(), done: observable(false)});
draft.value = '';
},
remove(item) {
todos.remove(item);
}
};
const handle = applyBindings(app, document.querySelector('#app'));
// When the page or component goes away:
// handle.dispose();Five things in there are worth pointing at:
done: observable(false), notdone: false. A plain property on an item is not reactive.observableArraytracks the array - pushes, removes, reorders - not the fields inside its items. Make a field observable and it updates; leave it plain and ticking the box changes nothing. This is the single most common early surprise.$parent.remove($data)is how a row reaches the list that owns it. Inside a list$datais the item, so a bareremovewould be looked for on the item and not found. This is the one place an expression may call a method - seedata-on-<event>.key=idis not optional fordata-each. With it, deleting the second row leaves the first row's actual DOM node in place, focus and all. Without it,applyBindingsrefuses the block and says so..valueeverywhere. There is no unwrapping magic: an observable is read and written through.value, in JavaScript and in a template alike, so the two never disagree about what a name means.data-bind-text, not{{ }}.applyBindingsdeliberately does not interpolate mustache in DOM that already exists - see why.
If you own the markup rather than the page, compile() takes a template string instead and gives
you {{ }}, {{#if}} and {{#each}} as well.
const count = observable(0);
count.value; // 0 - tracked: registers a dependency
count.value = 5; // write
count.set(5); // the same write, as a call
count.peek(); // read WITHOUT registering a dependency
count.subscribe(fn); // see below
count.extend({...}); // layer on behaviour - see Extenderspeek() and set() are closures, not methods, so they survive being destructured off the observable or handed straight
to a callback.
The equals comparator gates notification, not the write. A write always lands; the graph only hears about it when
the comparator reports a change, so a comparator that deliberately ignores part of the payload can never leave readers
serving stale data:
const user = observable({id: 1, seenAt: 0}, {equals: (a, b) => a.id === b.id});The default is deep equality. The corollary of gating at all: mutating the held value in place and assigning it back is invisible, because old and new are the same reference. Derivations must produce new values, not edit old ones.
const rows = observableArray([{id: 1}]);
rows.value; // the underlying array - tracked
rows.length; // tracked, so a rendered count updates
rows.peek(); // the live array, untracked
rows.value = [...]; // wholesale replacement - gated by `equals`
rows.push(item); // and pop, shift, unshift, splice, sort, reverse, fill, copyWithin
rows.remove(item); // every occurrence of that exact object
rows.remove(r => r.id > 2); // every item the test accepts
rows.removeAll();
rows.indexOf(item); // tracked, unlike peek().indexOf(item)
rows.replace(old, new); // swaps the first occurrence, in place
rows.destroy(item); // marks rather than removes - see below
rows.destroyAll();The in-place mutators run the native method and return exactly what it returns, so pop() gives you the item and
splice() gives you the removed slice.
Two write paths, two rules, deliberately. Mutators notify unconditionally - an in-place mutation leaves the array
equal to itself, so an equality gate could only be implemented by holding a copy and diffing it, at a full pass over the list per push.
Wholesale assignment is gated, exactly as observable() is. The accepted cost is a spurious notification from a
mutator that changed nothing (a no-op sort()), which errs towards notifying too often - the safe direction.
The initial array, and any array assigned wholesale, is copied rather than adopted. Holding your reference would
alias it: a push through the original would change what .value returns without ever reaching the graph, and the data
and the DOM would disagree silently and permanently. If you genuinely want the live array, take it from peek().
remove() takes either a value or a test. A value matches by identity, which is what a reconciled list wants -
$parent.remove($data) hands over the very object the row was rendered from. A function is called with (item, index)
and everything it accepts goes.
The one case this gives up is an array of bare functions removing one of its own members by passing it; peek() plus
splice() still covers that.
destroy() marks an item _destroy: true and leaves it in the array. That is Knockout's behaviour, and it exists
for one reason: Rails' accepts_nested_attributes_for deletes a record when the payload it receives carries that flag,
so the array must still contain the item at submit time while no longer showing it. Both render paths - {{#each}} and
data-each - skip a marked item, so the two halves agree. Outside that server convention, remove() is the right
call: it says what it does.
const total = computed(() => price.value * qty.value);
total.value; // recompute if stale, then return - and register a dependency
total.get(); // identical; `.value` is the form a template can useComputeds are lazy: the body runs on first read and then only when something it read has changed. Dependencies are
re-collected on every run, so a computed whose branch changed (mode.value === 'a' ? x.value : y.value) stops depending
on the branch not taken.
Prefer .value. It is the only spelling a template can use - an expression cannot call a method - and it means an
observable and a computed look the same at the point of use.
A computed is read-only unless you say where a write should land:
const celsius = observable(100);
const fahrenheit = computed({
read: () => celsius.value * 9 / 5 + 32,
write: (f) => { celsius.value = (f - 32) * 5 / 9; }
});
fahrenheit.value; // 212
fahrenheit.value = 32; // → celsius.value === 0That is what lets data-model="fahrenheit.value" bind a derived value. Without it the binding would assign onto the
cached read, the control would look wired up, and every keystroke would vanish on the next recompute - so assigning to a
computed with no write warns instead, naming it. The write runs untracked: a writer that reads a unit setting before
storing does not thereby depend on it.
.extend({…}) layers behaviour onto an observable after it exists, as Knockout's does.
const query = observable('').extend({rateLimit: 300});
query.value = 'a';
query.value = 'ab'; // one notification, 300ms after the typing stops
query.value; // 'ab' - the WRITE is never delayed, only the notification| Extender | Value | Effect |
|---|---|---|
rateLimit |
ms, or {timeout, method} |
hold notifications; method is 'notifyWhenChangesStop' (default) or 'notifyAtFixedRate' |
throttle |
ms | Knockout's older name for rateLimit |
notify |
'always' |
announce every write, including one the change gate would swallow |
The two rate-limit methods differ in what the window measures. notifyWhenChangesStop measures quiet - the window
restarts on every change, so continuous typing announces nothing until it stops. notifyAtFixedRate measures elapsed
time - the deadline is set by the first change of a burst and does not move, so a continuous stream announces once per
window. Neither ever delivers a stale value.
Knockout's original throttle delayed the write, which is why reading a throttled observable used to return a value
that was already out of date, and why Knockout deprecated it. That is not repeated here: the name is accepted and given
rateLimit's behaviour. A write always lands immediately, whatever is extended onto it.
rateLimit uses a timer, not the graph's microtask flush, so flushSync() does not deliver a held notification - a
test advances its own clock. Extending twice reconfigures the one limiter rather than nesting a second inside it, and
.extend({rateLimit: 0}) switches it off, dropping anything already waiting.
registerExtender(name, fn) adds your own. The handler is given a control surface with exactly two powers -
setEquals(fn) to replace the change gate and intercept(wrap) to wrap the announcement - and no way to touch the
stored value, which is what keeps the guarantee above true of every extender, including yours.
import {registerExtender} from 'domma-reactive';
registerExtender('trace', (control, label) => {
control.intercept(next => (value) => {
console.log(label, value);
next(value);
});
});
const count = observable(0).extend({trace: 'count'});const stop = effect(() => {
document.title = `${unread.value} unread`;
});
stop.dispose();An effect runs immediately, so its dependencies are collected up front, and re-runs on the microtask flush after any of them changes. It sits at the leaves of the graph: nothing depends on an effect.
untracked(fn) suspends collection for the duration of fn, which is how an effect reads something it must not
subscribe to:
effect(() => {
const rows = list.value; // tracked
untracked(() => analytics.send(rows.length)); // not
});const off = count.subscribe(value => console.log('now', value));
count.value = 1; // logs immediately - no flush needed
off(); // or off.dispose(), for Knockout muscle memorySubscribers fire synchronously, at the write, not on the flush that follows. You subscribed to a value, not to a
graph settling - so count.value = 5 followed by an assertion about the callback needs no flush.
They are not Computations, so a hundred subscriptions do not put a hundred nodes in the dependency graph. They follow
the same change gate: assigning a deeply equal value notifies nobody, and array mutators notify unconditionally.
A subscriber that throws is reported and skipped - one bad callback must not turn a write into an exception at an unrelated call site.
Writes never recompute anything synchronously. They mark computations dirty, queue them, and schedule one microtask flush, so a burst of writes collapses into a single propagation pass and a single render:
first.value = 'Ada';
last.value = 'Lovelace';
age.value = 36;
// → one flush, one re-renderflushSync() drains the queue immediately and synchronously. It is what a test uses to assert on the DOM without
awaiting a microtask:
import {flushSync} from 'domma-reactive';
rows.push({id: 2});
flushSync();
expect(host.querySelectorAll('li')).toHaveLength(2);An effect is a live node in the dependency graph, and dropping the DOM does not drop it. Every entry point returns something to tear down with, and you must call it:
| Created by | Torn down by |
|---|---|
effect(fn) |
.dispose() on the returned Computation |
compile(…) |
controller.destroy() |
applyBindings(…) |
handle.dispose() |
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.
compile() turns a mustache template into a set of fine-grained bindings, each owning a small region of the DOM. A
structural change re-renders only the block that changed - everything else keeps its node identity, so focus, scroll
position and unsaved input survive.
import {compile} from 'domma-reactive';
const host = document.querySelector('#out');
const controller = compile('<p>Hello {{name}}</p>', {name: 'Ada'}, host);
// → <p>Hello <span data-dm-t="0_txt">Ada</span></p>
controller.updateAll({name: 'Grace'}); // only the span is touchedcompile(template, data, container, renderFn?, options?). Pass {reactive: true} and every binding gets its own
effect, collected from what it actually reads:
const name = observable('alice');
const controller = compile('<b data-bind-text="name.value"></b>', {name}, host, undefined, {reactive: true});
name.value = 'bob'; // the <b> follows, with nothing else to doIt is off by default because Domma wires its own. A standalone consumer almost certainly wants it on. Either way, list items always own their effects - nothing else is in a position to.
The controller:
bindings |
the binding records, each with id, kind, expr, deps |
deps(id) |
the root names one binding reads - how you subscribe an effect to exactly the right state |
update(id, data) |
re-run one binding |
updateAll(data) |
re-run all of them |
context() |
the binding context in force |
destroy() |
tear everything down |
Five come from mustache syntax:
| Kind | Template | Updated by |
|---|---|---|
text |
{{name}} |
textContent on a <span> anchor |
attr |
class="{{cls}}" |
setAttribute on the owning element |
block |
{{#if x}}…{{/if}} |
re-rendering a comment-delimited region |
raw |
{{{html}}} |
re-rendering a comment-delimited region |
each |
{{#each xs key=id}}…{{/each}} |
reconciling, per item - see below |
Seven come from data-* attributes. Attributes rather than {{ }} because {{ }} produces a string, and events,
two-way binding and focus all need a reference to a DOM element that survives rendering:
| Attribute | Purpose | Example |
|---|---|---|
data-on-<event> |
event binding, any DOM event | data-on-click="save" |
data-bind-<name> |
one-way to a property, class, style or attribute | data-bind-text="user.name" |
data-model |
two-way, control ↔ data | data-model="query" |
data-if |
conditional without a block | data-if="isOpen" |
data-options |
populate a <select> |
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 expression is either a reference that evaluates to a function, or a call:
<button data-on-click="save">Save</button>
<button data-on-click="remove(item, 2)">Delete</button>
<button data-on-click="$parent.remove($data)">Delete</button>Your declared arguments come first and the event is always the last argument, so a handler that wants only the event
and one that wants arguments are spelled the same way round. Returning false calls preventDefault().
The callee is resolved against your data, not against the helper registry - an event handler is a method on your data, and the evaluator is right to refuse to call one during a render.
A method call is allowed here and nowhere else. Inside a list $data is the item, and a bare name resolves against
$data only, so $parent.remove($data) is how a row reaches the list that owns it. Everywhere else - {{ }},
data-if, data-bind-* - x.foo() is still a parse error, because those are reads that run inside an effect and a
call during a read is a side effect. An event fires on a gesture, outside every effect.
this follows JavaScript's own rule, which is easier to remember than any rule this library could invent:
| Expression | this |
Why |
|---|---|---|
save |
$data |
a reference; no receiver was named |
save(x) |
$data |
a bare callee is a name on $data |
handlers.save |
$data |
still a reference - nothing is called |
handlers.save() |
handlers |
a method call keeps its receiver |
The last two are exactly const f = o.m; f() versus o.m().
The method name is read through the same guard as every other property read, so $data.constructor() is refused for the
same reason {{ $data.constructor }} is.
Event bindings declare no dependencies: the listener is attached once and reads the context at dispatch time, so there is nothing for an effect to re-run.
The suffix is the target:
| Suffix | Effect |
|---|---|
text |
textContent |
class |
adds/removes only the tokens this binding applied last time |
style |
an object of CSS properties - see below |
style-<property> |
one CSS property, e.g. data-bind-style-font-weight |
value checked disabled readonly required selected multiple indeterminate open hidden |
the DOM property |
| anything else | an attribute of that name |
For an attribute, false / null / undefined removes it and true sets it to the empty string, so
data-bind-aria-hidden="collapsed" behaves the way you would expect rather than rendering the string "false".
data-bind-class is additive on purpose. el.className = value would delete every static class on the element, so the
handler remembers the tokens it applied and swaps only those:
<p class="card" data-bind-class="isActive && 'on'"></p>A falsy value contributes no classes at all, which is what makes that idiom work - isActive && 'on' is false, not
'', when it is off.
There is no data-bind-html. Assigning innerHTML from data is the shortest route to a cross-site scripting (XSS)
hole, and the template
already has an explicit, greppable opt-out for it: {{{triple-stache}}}. Using the attribute logs one warning and
writes nothing.
<p data-bind-style-color="shade"></p> <!-- one property -->
<p data-bind-style-font-weight="weight"></p> <!-- kebab-cased -->
<p data-bind-style---brand="accent"></p> <!-- custom property -->
<p data-bind-style="look"></p> <!-- {color, fontWeight, …} -->Knockout writes style: {color: shade} because it compiles binding strings with the Function constructor. Since 1.2
this expression language parses plain object literals too (see Expressions), so
data-bind-style="{color: shade}" works - but the single-property attribute stays the recommended spelling: it is the
common case, it reads one property at a time, and the object case can equally take an object the view model already
holds. In an object, camelCase keys
are converted; in the attribute they are kebab-cased, because an HTML attribute name is lowercased by the parser and
data-bind-style-fontWeight would arrive as fontweight.
A falsy value removes the property, so data-bind-style-color="isError && 'red'" works the way
data-bind-class does. 0 is not treated as falsy here - opacity: 0 is a real value. Ownership follows the same rule
as class: only the properties this binding set last time are removed, so a static style="margin: 4px" survives.
No unit is ever added. data-bind-style-width="w" with w = 40 sets width: 40, which the browser ignores; write
w = '40px'.
Two-way. The expression must be a settable path - a bare name, or a member chain ending in one:
<input data-model="query">
<input data-model="user.email">
<input data-model="rows[i]">At write time the object part is evaluated and the last step is used as a key, so the write lands where the read came
from. Anything that is not a path (a comparison, a helper call, $data/$root/$parent/$index) logs one warning and
writes nothing, because a binding you cannot write through is not two-way. __proto__, constructor and prototype
are refused as keys, in every form, including a[k] where k holds one of them at runtime.
There is no observable-unwrapping magic. A tracking proxy is written as data-model="name"; a standalone observable
is written as data-model="count.value", which is the same .value you read it through. Both are ordinary property
assignments:
const count = observable(0);
compile('<input type="number" data-model="count.value">', {count}, host);| Control | Property | Listened events |
|---|---|---|
| checkbox | checked (boolean) |
change |
| radio | checked, against its value |
change |
select[multiple] |
an array of selected values | change |
select |
value |
change |
number, range |
value coerced to a Number, empty → null |
input, change |
| everything else | value |
input, change |
An unchecked radio writes nothing, so the group's value is not cleared by the sibling that lost the selection. The data → DOM direction writes only when the value actually differs, so re-rendering while someone is typing does not move their caret.
The element is in the DOM, or it is not - it is not hidden with CSS. A binding named after a conditional that leaves the
element focusable and read by a screen reader would be lying; use data-bind-hidden if that is what you want.
<div data-if="isOpen">…</div>Truthiness is mustache truthiness, so an empty array is falsy and {{#if items}} and data-if="items" cannot
disagree. Read the observable's value - data-if="isOpen.value" when isOpen is an observable. Naming the observable
itself is caught: see An observable is not its value. Toggling re-renders the element rather than stashing and restoring it, so bindings inside it can never go
stale - at the cost of node identity across a toggle, exactly as {{#if}} has always behaved.
Populate a <select> from a collection.
<select data-options="cities" data-model="chosen"></select>
<select data-options="people"
data-options-text="first + ' ' + last"
data-options-value="id"
data-options-caption="'Anyone'"
data-model="assignee"></select>| Attribute | Meaning |
|---|---|
data-options |
the collection |
data-options-text |
the label - an expression against the item; defaults to the item |
data-options-value |
the value - likewise |
data-options-caption |
a leading option with an empty value |
{{#each cities}}<option>{{.}}</option>{{/each}} produces the same markup. What it does not produce is the
selection: rebuilding a select's options resets it, and the selection lives on the select rather than on any item,
so a keyed list has nothing to preserve it with. This binding rebuilds and puts the selection back - which is the only
part you cannot easily write yourself.
The three companions are expressions evaluated in the item's own context, so $index, $parent and $root all
resolve, and a label can be computed. Knockout takes a property name here, which cannot express that. The cost is
that a literal caption needs its quotes: data-options-caption="'Anyone'".
Values need not be strings. An <option>'s value is always a string, so when the resolved value is not one the
real value is kept alongside it and data-model reads back the object or the number that went in - not
"[object Object]". With no data-options-value at all, the value is the item, matched by identity.
Order does not matter, and neither does timing: <select data-model="chosen" data-options="cities"> works, and so does
a list that arrives from a fetch long afterwards. A value the model asked for while no option carried it is remembered
and applied by the rebuild that brings it.
Two-way, between a value and focus. Knockout calls this hasFocus.
<input data-model="title" data-focus="editingTitle">Setting editingTitle to true moves focus into the field; the user tabbing in sets it to true; blurring sets it to
false. Both directions earn their place - the first is how a view model puts the caret in the field it has just
revealed, without reaching for a DOM node; the second is how it knows where the user is without wiring up listeners.
Unlike data-model, an expression it cannot write through is not fatal: data-focus="isEditing && !isSaving" is a
sensible way to drive focus from derived state, so the value → focus direction keeps working and only the write-back
warns.
registerBinding() adds a kind. It is not a side door: all ten built-ins are registered through this exact
function, so anything a built-in does, a custom binding can do.
import {registerBinding, compile} from 'domma-reactive';
registerBinding('shout', {
attribute: 'data-shout', // or attributePrefix: 'data-shout-'
expression: true, // parse the value; binding.evaluate is set
tracks: true, // contribute the expression's deps
primes: true, // run update() once after the first paint
update({binding, nodes, context}) {
const value = String(binding.evaluate(context) ?? '');
for (const el of nodes) el.textContent = value.toUpperCase() + '!';
return true;
}
});
compile('<p data-shout="name"></p>', {name: 'ada'}, host); // → <p>ADA!</p>update is required; attach({binding, node, controller}) and detach(…) are optional and are what data-on-* and
data-model use to add and remove listeners. region: true wraps the owning element in comment anchors and fills
binding.body, which is how data-if works. Register before compiling - a template already compiled does not pick up
a new kind. The full contract is documented at the top of src/handlers.js.
The one thing a custom binding cannot do is invent {{ }} syntax: mustache is a fixed grammar, attributes are
open-ended. Every handler is otherwise the same shape and dispatched by the same call.
Expressions resolve against a context, not a bare data object:
| Name | Meaning |
|---|---|
$data |
the object names resolve against |
$root |
the top-level data, however deep the nesting |
$parent |
the enclosing data (not the enclosing context) |
$parents |
all ancestor data, nearest first - $parents[0] is $parent |
$parentContext |
the enclosing context - the one name here that is one |
$index |
position within a list |
$length |
size of the enclosing list |
$component |
the enclosing component's view model |
All eight resolve everywhere. Outside a list or with block, $data and $root are the top-level data, $parents is
empty, and $parent, $parentContext, $index, $length and $component are null - so a binding never has to ask
where it is.
Pass plain data anywhere a context is accepted and it is promoted for you.
import {createRootContext, createChildContext} from 'domma-reactive';
const root = createRootContext({title: 'People'});
const child = createChildContext(root, {name: 'Ada'}, 0);
child.$parent.title; // 'People'
child.$index; // 0There is no scope-chain walk: a bare name resolves against $data only. Reach a level up with $parent.name, which
says what it means.
$parents is ancestor data, nearest first, so $parents[1] is a grandparent. $parentContext is the enclosing
context, which is how you reach a thing that is not data at all - the enclosing list's position:
<ul>{{#each groups key=id}}
<li>
<h3>{{name}}</h3>
<ol>{{#each members key=id}}
<li>
{{name}}
- in {{$parents[0].name}} <!-- the group: same as $parent.name -->
- of {{$parents[1].title}} <!-- the root: nothing else reaches it -->
- group {{$parentContext.$index}} <!-- the OUTER list's position -->
</li>
{{/each}}</ol>
</li>
{{/each}}</ul>$parents is built only if a template asks for it, by walking $parentContext on first read, so a list that never
mentions the name pays nothing for it.
Both are frozen, as every context is. Writing to $parents[0] or to a context reached through $parentContext logs one
warning and does nothing - write to ancestor data instead, which $parents[1].name = x does.
A binding reads a value. data-if="show", where show is an observable(false), names the observable object itself -
and every object is truthy, so before 1.2 the content rendered and nothing said why. Now a binding whose expression
resolves to an observable, an observable array or a computed:
- warns once, naming the expression and the fix:
"show" is an observable, not its value - use "show.value"; - reads it as empty - fail closed:
data-ifand{{#if}}hide,data-bind-disabled/-hidden/-checkedare off, text and attributes are written as'', never"[object Object]".
The same holds when the observable is an operand: !show, count > 3, active && 'on'. The whole expression reads as
undefined - so !show cannot flip open either - with one warning naming the operand.
<p data-if="show">Never shown, with one warning</p>
<p data-if="show.value">Shown when show is true</p>There is no automatic unwrapping, on purpose: one spelling, .value, everywhere. Two deliberate exceptions, where the
observable itself is what is wanted: a keyed list takes an observableArray directly ({{#each rows key=id}},
data-each="rows key=id"), and data-param-* passes whatever it is given to a component, by reference.
Bindings inside an unkeyed {{#each}}, and inside {{#with}}, are not bound independently - the block re-renders as
a whole, so a behaviour binding inside one is not attached. An unkeyed {{#each}} says so in its single "no key="
warning, naming every binding it dropped - add key= and every one of them works; see Keyed lists. Inside
{{#with}} there is no key to add: write the full path (data-model="obj.field") without the block.
{{> partial}} inside a keyed block is not expanded. The block body is compiled once into a <template>, before any
render pass exists to resolve a partial against. Inline it, and the compiler says so if you do not.
data-each is an applyBindings spelling, and the one place it does not work is inside another list. A list's item
template is compiled markup - the same compiler compile() uses - and the compiler discovers lists from {{#each}}
only. A nested data-each is therefore inert: the attribute is left as written and the bindings inside it resolve
against the outer item. It warns, naming the mustache form to use instead.
Nest with {{#each}}, which reconciles at any depth and works inside a data-each body - mustache is meaningful there
precisely because that body is compiled:
<ul data-each="groups key=id">
<li>
{{#each members key=id}}<b>{{name}} - {{$parentContext.$index}}</b>{{/each}}
</li>
</ul>{{#each items key=id}} reconciles. An item that stays in the collection keeps its DOM nodes and its effects across
any change to the list, so focus, half-typed input, scroll position, CSS transitions and media playback all survive.
const data = {rows: [{id: 1, name: 'Ada'}, {id: 2, name: 'Grace'}]};
const controller = compile(
'<ul>{{#each rows key=id}}<li>{{$index}}: {{name}}</li>{{/each}}</ul>',
data, host
);
data.rows = [{id: 3, name: 'Katherine'}, ...data.rows];
controller.updateAll(data);
// Ada's <li> is the same node object it was before. It was moved, not rebuilt.key= names the property that identifies an item; a dotted path (key=meta.ref) works too. It must be an identity, not
a value - a key that changes when the item's contents change defeats the whole mechanism.
Without key= the block falls back to re-rendering wholesale and says so once, naming the template. Nothing breaks;
it simply costs you node identity. Pass {warnUnkeyed: false} in the compiler options to silence it.
Everything. Each item gets its own binding context and its own effects:
{{#each rows key=id}}
<li data-bind-class="done && 'complete'">
<input data-model="title">
<button data-on-click="$parent.remove($data)">×</button>
{{#if note}}<small>{{note}}</small>{{/if}}
{{#each tags key=id}}<span>{{$parent.title}}/{{name}}</span>{{/each}}
</li>
{{/each}}$parent.remove($data) calls remove on the parent view model with the clicked row as its argument. That is the one
place a method call is permitted in an expression - see data-on-<event>.
The renderer's loop variables ({{.}}, {{@index}}, {{@first}}, {{@last}}) resolve inside a keyed block too, so
adding key= to an existing block never silently blanks anything.
A keyed block inside an unkeyed {{#each}} or a {{#with}} is demoted to an ordinary re-rendered block, with a
warning. Its collection expression would otherwise be evaluated against the top-level data, where the name means
nothing, and the list would render empty on a page that looks finished.
Add key= to the enclosing block and both reconcile - nesting keyed lists inside keyed lists is fully supported, to
any depth.
Each item is an instance: a pair of comment anchors, the nodes between them, a context, and one effect per binding. An instance is disposed - effects first, then nodes - when its key leaves the collection, when an enclosing region re-renders over it, or when the controller is destroyed.
Placement is in order. That is correct for append, prepend, insert, remove and reorder, and it performs more DOM moves than strictly necessary - reversing n items costs n moves rather than n-1, and dragging one item from the end to the front costs n rather than 1. The refinement is longest-increasing-subsequence move minimisation - an algorithm that works out the smallest set of moves that will do. Nothing about correctness or node identity depends on it: an instance that is moved is the same instance, with the same nodes and the same effects.
A component is a piece of markup with its own private state and its own teardown. Register one, then render it anywhere:
import {registerComponent, observable, computed} from 'domma-reactive';
registerComponent('contact-card', {
template: `
<div class="card">
<b data-bind-text="contact.name.value"></b>
<button data-on-click="toggle" data-bind-text="label.value"></button>
<input data-if="editing.value" data-model="contact.name.value">
</div>`,
create(params) {
const editing = observable(false);
return {
contact: params.contact,
editing,
label: computed(() => (editing.value ? 'done' : 'edit')),
toggle() { editing.value = !editing.value; }
};
}
});<div data-component="'contact-card'" data-param-contact="$data"></div>editing belongs to the card. Ten cards on a page have ten of them, and none can see the others - which is the whole
reason to reach for a component rather than a {{#each}} body with an editingId held on the list.
The name is an expression, like every binding value here, which is why a literal one takes inner quotes. That costs a pair of quotes in the common case and buys dynamic components for nothing.
create is a plain factory - no new, no constructor form, no second createViewModel spelling. It receives the
params and {element}, and returns the object the template resolves against.
Leave it out and the component is template-only, with the params themselves as $data:
registerComponent('badge', {template: '<b class="badge" data-bind-text="label"></b>'});<span data-component="'badge'" data-param-label="status"></span>That keeps the trivial case trivial, which is where Knockout's viewModel-or-constructor ambiguity does the most
damage.
<!-- one param named in the attribute - the common case -->
<div data-component="'contact-card'"
data-param-contact="$data"
data-param-editable="canEdit"></div>
<!-- an object the view model already holds - many params at once -->
<div data-component="'contact-card'" data-params="cardParams"></div>This is the pair data-bind-style established and data-options-* followed, and it exists for the
same reason: Knockout spells params as an object literal. data-params="{a: b}" parses since 1.2, but one param named in
an attribute is the common case, and it is the one an object literal makes awkward.
Both forms may appear together. They merge, and a named attribute beats the same key in the object - the more specific spelling wins. A collision warns once, because it is almost always a mistake.
Attribute names are kebab-case and arrive camelCased: data-param-first-name is params.firstName. An HTML attribute
name is lowercased by the parser, so data-param-firstName could not survive the round trip.
data-param-* on an element with no data-component warns at compile time. Nothing would ever read it, and the
symptom - a component whose every param is undefined - is indistinguishable from a typo in the component name.
Nothing decides this. It falls out of reads being explicit:
| Markup | The view model receives | Can it write back? |
|---|---|---|
data-param-contact="user.name" |
the observable itself | yes - the parent sees the write |
data-param-contact="user.name.value" |
a snapshot of the value | no |
Knockout needs a documented convention here and its users still get it wrong, because params: {a: x} and
params: {a: x()} look equally plausible at a glance. Here they are different expressions, and the difference is the
same .value you already read through.
Params are evaluated once, when the instance is created - a constructor argument, not a live binding. Observables
stay live because they are references; plain expressions are snapshots. The params object is frozen, because it is an
input rather than scratch space; the observables inside it stay writable through .value, which is the intended path
back to the parent.
At the top of a component's template, $data is the view model, so a template reads its own state unqualified. Inside a
{{#each}} it is not - $data is the row. $component still reaches the view model:
<!-- inside a component's own template -->
{{#each rows key=id}}
<li>
{{name}}
<button data-on-click="$component.select">select</button>
</li>
{{/each}}It is inherited by child contexts exactly as $root is, so it keeps answering however deep the nesting goes - which is
the only reason the name exists. It is null outside a component.
A component is a boundary for $data and nothing else: $parents and $parentContext
see straight through it, so a component rendered inside a list can still reach that list's data and its $index.
Point data-component at an observable and the rendered component follows it:
<div data-component="currentView.value"></div>currentView.value = 'settings-panel'; // the editor is disposed, the panel is builtThis is how a great many Knockout applications route. Changing the name tears the old instance down - view model
dispose() first, then its effects, then its nodes - and builds the replacement, re-evaluating every param against the
context in force at that moment. Setting the name to what it already is does nothing at all, so an unrelated update
never costs you a half-typed input.
A component renders inside its element rather than replacing it, as Knockout's component: does. The host keeps its
own attributes, classes and identity across a swap, and create(params, {element}) receives it.
Its original children are not discarded: they are the component's slot content.
A component can leave holes for the page to fill. {{#slot}} in the template marks one; data-slot at the usage site
says which:
registerComponent('card', {
template: `
<div class="card">
<header>{{#slot header}}<h2>Untitled</h2>{{/slot}}</header>
<div class="body">{{#slot}}{{/slot}}</div>
<footer>{{#slot footer}}<button data-on-click="close">Close</button>{{/slot}}</footer>
</div>`
});<div data-component="'card'">
<h2 data-slot="header">Ada Lovelace</h2>
<p>Mathematician.</p>
<button data-slot="footer" data-on-click="save">Save</button>
</div>Anything without a data-slot goes to the default slot, text included. Several elements may share a name; they arrive in
document order.
The block body is the fallback, used only when nothing is projected. {{#slot header}}<h2>Untitled</h2>{{/slot}}
renders that heading when the page supplies no header, and nothing of it when the page does.
This is the part worth reading twice.
| Markup | Compiled by | Resolves against |
|---|---|---|
the fallback, inside {{#slot}} |
the component's template | the component's view model |
| projected content, inside the host element | the page | the outer context |
<div data-component="'card'">
<b data-bind-text="user.name.value"></b> <!-- the PAGE's user, always -->
</div>A component cannot inject values into its slot content, and does not need to: the content was compiled where it was written and never leaves that scope. This is Knockout's model. To hand something outward, pass a callback param:
<div data-component="'list'" data-param-on-select="choose"></div>Projected content keeps working after it is placed - it is the same DOM, moved. A data-model inside a slot writes
straight back to the page's observable, and a swap to a different component carries the same nodes across rather than
rebuilding them.
Because an element does not survive the HTML parser, and the components that most want slots are the ones that break:
| Written | Parsed as |
|---|---|
<tr><dm-slot></dm-slot></tr> |
<dm-slot></dm-slot><table>... - hoisted out of the table |
<select><dm-slot></dm-slot></select> |
<select></select> - deleted |
<tr><!--dm:slot--><!--/dm:slot--></tr> |
survives intact |
A mustache block compiles to comment anchors, which sit anywhere - the same reason {{#each}} already works inside a
<tbody>. The usage site still has to be valid HTML of course: to project real <tr>s, the host element is the
<tbody>.
{{#slot}} registers no binding and evaluates no expression. It is a marker, not a thing that runs.
| Situation | Behaviour |
|---|---|
data-slot="x" and the component has no slot x |
warn once naming x, that content is left out |
content given to a component with no {{#slot}} at all |
warn once - it would vanish silently otherwise |
| two slots with the same name in one template | warn once, the first is filled, the rest keep their fallback |
| a slot left empty | nothing - that is what fallback content is for |
If the view model has a dispose(), it runs on teardown, before the instance's effects are destroyed:
create(params) {
const timer = setInterval(tick, 1000);
return {
dispose() { clearInterval(timer); }
};
}There are no other lifecycle hooks. dispose() is the same contract handle.dispose() and controller.destroy()
already offer, and adding more would be inventing the framework this package is not.
Teardown happens when the name changes, when an enclosing region re-renders over the host, when the host's key leaves a
list, or when the controller is destroyed. A dispose() that throws is warned about and stepped over - one component
failing to clean up must not stop the next one from cleaning up.
As everywhere else: one warning naming the expression and the template, and that component alone is skipped.
| Situation | Behaviour |
|---|---|
| The name expression does not parse | warn once, host left empty |
| The name is not a string | warn once naming the quoting rule, host left empty |
| No component registered under that name | warn once naming the name, host left empty |
| A param expression does not parse | warn once, that param is absent from the object |
create() throws |
warn once, host left empty, no instance registered |
dispose() throws |
warn, and teardown continues |
registerComponent is the exception: it throws on a bad definition, as registerExtender does. A bad registration
is a programming error at startup, where a bad expression is authored data met halfway through a paint.
The other direction from compile(). Point it at HTML that already exists - server-rendered, hand-written, whatever -
and it activates the binding attributes in place, leaving the markup otherwise as it found it. No build step, no second
source of truth for the markup.
<div id="app">
<h1 data-bind-text="title">Rendered by the server</h1>
<button data-on-click="save">Save</button>
<input data-model="query.value" value="rendered by the server">
<p data-if="showHelp">Help text.</p>
<ul data-each="rows key=id">
<li data-bind-text="name">template row</li>
</ul>
</div>import {applyBindings, observable, observableArray} from 'domma-reactive';
const handle = applyBindings({
title: 'Live',
query: observable(''),
showHelp: false,
rows: observableArray([{id: 1, name: 'Ada'}]),
save() { /* … */ }
}, document.querySelector('#app'));Every binding gets its own effect, so a view model built from observables updates itself. For a plain, untracked object,
handle.update(data) re-runs everything.
Note data-model="query.value", not data-model="query" - query holds an observable, and the
no-unwrapping rule applies in a binding exactly as it does in JavaScript. Binding the bare name warns
("An observable is not its value"), leaves the input empty, and would replace the
observable on the first keystroke.
| Returns | {bindings, context(), update(data), dispose()} |
| Idempotent | applying twice skips elements already bound and warns once, naming the root |
| Disposable | dispose() drops every effect, listener, list instance and marker it created, restores a hidden data-if element, and leaves the markup as it was found |
Deliberately, and it says so once if it finds a token that looks like a binding.
There is nothing coherent to do with it. Either the server rendered the value - in which case the token is gone and there is only text that happens to say "Ada" - or the server emitted the raw token, in which case the page was broken until JavaScript ran, which is the thing server rendering exists to avoid. Guessing which text nodes are dynamic is not possible, and rewriting every text node into anchored spans would mutate, destructively, the markup this function promises to leave alone.
data-bind-text="expr" is the supported spelling. It is explicit, greppable, and the server can render the text and the
attribute together.
The one exception is the contents of a data-each, which are a template rather than rendered output: they are lifted
out of the document, compiled and cloned per item, so mustache works there because there it means something.
applyBindings implements data-if by removing the element and putting the same node back, so it keeps its
children, its listeners and its focus across a toggle. compile() cannot do that - while an element is detached, the
bindings inside it are invisible to re-indexing, so it would come back stale - and re-renders its region instead. This
is the one place the two entry points differ in behaviour rather than in input.
A custom binding declaring region: true is refused by applyBindings, with an explanation: a region handler
re-renders from a captured template body, and here the markup is the page.
A binding attribute needs an element to sit on. Sometimes there is none to spare - a run of <li>s, three <td>s in a
row, a fragment inside a <p> - and wrapping them in a <div> to carry the attribute changes the layout, or inside a
table is not even valid HTML a browser will keep. Comments have no such problem.
<ul>
<li>Always shown</li>
<!-- dm if: showExtras -->
<li>Only when showExtras</li>
<li>These two travel together</li>
<!-- /dm -->
<!-- dm each: rows key=id -->
<li data-bind-text="name"></li>
<!-- /dm -->
</ul>
<p>Signed in as <!-- dm text: user.name -->…<!-- /dm -->.</p>This is Knockout's <!-- ko if: x --> … <!-- /ko -->, and it is the one thing applyBindings genuinely could not
express. compile() has never needed it, because {{#if}} already delimits a region with comments of its own - so
these exist only for markup that arrived from a server, where the author cannot add mustache.
| Form | Behaviour |
|---|---|
<!-- dm if: expr --> |
the run of nodes is in the document, or held aside - the same nodes come back, with their listeners and their focus |
<!-- dm each: expr key=id --> |
the run is the item template, lifted and compiled exactly as data-each is; key= is required for the same reason |
<!-- dm text: expr --> |
one text node between the anchors, replacing whatever the server put there as a placeholder |
Every closer is <!-- /dm -->, whatever it closes. They nest, and a block held out of the document keeps its nodes'
sibling relationships, so a nested block that changes while its parent is closed still lands correctly when the parent
reopens.
Two limits, both warned about rather than silent: an opener with no <!-- /dm --> is skipped, and a virtual binding
inside a virtual list's body is not read - that body is compiled as a template, and the compiler knows mustache, not
comments. Its content is left out of every row (fail closed: before 1.2 it rendered unconditionally, so a hidden
<!-- dm if --> showed in every row). Use {{#if flag}} inside a list body - it is scoped to the item - or data-if on
an element.
compile(template, data, container, renderFn) still takes the mustache renderer as a parameter, and a caller who passes
one gets exactly that. The parameter is optional, and the default is renderTemplate, exported so you can use it on
its own:
import {renderTemplate} from 'domma-reactive';
renderTemplate('{{#each xs}}<li>{{name}}</li>{{/each}}', {xs: [{name: 'a'}]});
renderTemplate('{{> row}}', {n: 1}, {partials: {row: '<i>{{n}}</i>'}});It supports {{x}}, {{{x}}}, {{#if}} / {{else}}, {{#unless}}, {{#each}}, {{#with}}, {{> partial}},
{{.}}, {{@index}}, {{@first}}, {{@last}} and {{! comments }}. Interpolations escape; triple-staches do not.
Domma passes its own utils.render and is unaffected by any of this. But the two are not identical, and the
differences below were verified against utils.render at Domma v0.33.1 rather than assumed:
| Case | Domma's utils.render |
renderTemplate |
|---|---|---|
Same-kind nesting - {{#each}} inside {{#each}} |
matches the inner {{/each}}, producing broken output |
counts depth; correct |
{{else}} inside a nested {{#if}} |
binds to the outer block | binds to its own block |
{{.}} over a list of primitives |
[object Object] |
the item |
Expressions - {{ n > 1 ? 'many' : 'one' }} |
empty | evaluated |
{{#if n > 2}} |
always falsy | evaluated |
{{helper arg}} (space-separated) |
calls a registered helper | not supported - renders empty, no warning. Use helper(arg) |
Escaping, missing values, {{#each}} item scope, {{@index}}, {{#with}}, kebab-case keys |
identical |
The first four rows are cases Domma's renderer does not handle correctly and this one does, so a template that works
under Domma works here. The reverse is not guaranteed - an expression or a nested same-kind block written against
this renderer will not survive a move to utils.render.
A {{ }} becomes a live text binding when it is a dotted path, or when it contains unambiguous operator syntax and
parses. {{.}}, {{@index}} and {{helper arg}} are left to the renderer, and never warn.
- and + count as operators only with whitespace around them, so {{first-name}} reads a kebab-case key and
{{ a - b }} is arithmetic.
An expression interpolation is evaluated once immediately after the first paint, because the injected renderer may not
understand it. data-bind-*, data-model and data-if are primed the same way, for the same reason: there is no
{{ }} token in an attribute for a renderer to substitute.
{{{raw}}} and class="{{cls}}" still go through the renderer and still accept dotted paths only. Use data-bind-*
for an expression-valued attribute.
Bindings need more than a dotted path, so the package ships a small expression language - parsed by hand, never by the
Function constructor.
import {compileExpression, registerHelper} from 'domma-reactive';
registerHelper('upper', s => String(s).toUpperCase());
const evaluate = compileExpression("count > 0 ? upper(label) : 'none'");
evaluate({count: 3, label: 'items'}); // 'ITEMS'
evaluate({count: 0, label: 'items'}); // 'none'compileExpression parses once and returns a function; call that per update. It returns null if the source does not
parse, so a caller can skip the binding rather than render a lie.
This is the engine every binding runs on: data-bind-*, data-model, data-if, data-on-*, non-path {{ }}
interpolations and the default renderer all evaluate through it.
expressionDependencies is what lets you wire one effect per binding without guessing:
expressionDependencies("label === 'name'"); // Set { 'label' } - not 'name'
expressionDependencies('user.profile.email'); // Set { 'user' } - root names only
expressionDependencies('$parent.name'); // Set {} - position, not state| Category | Forms |
|---|---|
| Paths | a, a.b.c, a[0], a[key], a['x'] |
| Literals | 'str', "str", 1, 1.5, 1e3, true, false, null |
| Objects | {a: x, 'b-c': y} - name or string keys |
| Arrays | [a, b, 'c'], and [a, b][i] |
| Arithmetic | + - * / % (+ also concatenates) |
| Comparison | === !== < <= > >= |
| Logical | && || ! - short-circuiting |
| Ternary | a ? b : c |
| Unary | - + ! |
| Calls | helper(arg, …) - built-in or registered helpers only |
| Context | $data, $root, $parent, $parents, $parentContext, $index, $length |
Precedence and associativity are JavaScript's. 1 + 2 * 3 is 7; 10 - 3 - 2 is 5. Nesting is capped at 64 levels.
Object and array literals build a fresh value on every evaluation, so a binding never shares one object between
renders. They are deliberately plain: no computed keys ({[k]: 1}), no shorthand ({a}), no spread, no methods, no
holes ([1,,2]) and no trailing commas - each refused with a message saying so - and __proto__, constructor and
prototype are refused as keys, so a literal is no way round the read guard. Use them in attribute bindings and helper
calls (data-bind-style="{color: tone}", {{fmt(price, {currency: 'GBP'})}} inside an attribute). Inside {{ }} text a
closing }} ends the interpolation, so keep literals with braces out of mustache text.
Expressions cannot call methods, so these ship ready to call - no registerHelper needed:
| Helper | Returns |
|---|---|
len(list) |
length of an array or string, size of a Set or Map, 0 for anything else (including null) |
includes(list, value) |
whether the list holds value (===), or a string contains it |
some(list, key[, value]) |
whether any item's key is truthy - or === value, when given |
every(list, key[, value]) |
whether every item matches; true for an empty list |
count(list[, key[, value]]) |
how many items match; with no key, how many items |
where(list, key[, value]) |
the matching items, as a new array |
sum(list[, key]) |
total of the items, or of each item's key; non-numbers count as 0 |
pluck(list, key) |
each item's key, as a new array |
sortBy(list[, key[, 'desc']]) |
a sorted copy; strings by localeCompare, missing values last |
first(list) / last(list) |
an end item, or undefined |
join(list[, separator]) |
a string, ', ' between items by default |
key is a property name or a dotted path ('meta.tag'); pass null to test the items themselves
(where(xs, null, 2)). The list may be an observable or observableArray, and a field may be an observable - both are
read for you, and the reads are tracked, so a binding re-runs when the list changes or when one row's field does:
<p data-if="!len(todos)">Nothing to do.</p>
<span data-bind-text="count(todos, 'done') + ' of ' + len(todos)"></span>
<ul data-each="where(todos, 'done', false) key=id"><li data-bind-text="title"></li></ul>
<span data-bind-text="join(pluck(tags, 'name'))"></span>None of them mutates its input. A helper you register under the same name takes precedence; unregistering it brings the built-in back.
Assignment. new. Member calls - user.toUpperCase() does not work, and neither does alert(1); the only callable
things are helpers - built-in or registered. (data-on-* is the single exception, and only because an event fires outside every
effect - see data-on-<event>.) Loose equality (==), nullish coalescing (??), regular expressions,
template literals, comma sequences. Reads of __proto__, constructor and prototype, in
any form - including a[key] where key holds '__proto__' at runtime.
Most of those are recognised specifically so they can be refused with a message that says what to do instead. Anything
more complicated than the grammar above belongs in a computed, not in a template.
A malformed expression logs one warning naming the source (and the template, if you passed {template: 'user-card'})
and yields null from parseExpression / undefined from evaluateExpression. An evaluation error - a helper that
threw, a nesting depth beyond 64 - does the same. Nothing in this module throws on expression input, so one bad binding
cannot blank a page.
The exception is registerHelper, which throws a TypeError on a bad name or a non-function. That is a bug in your
code, not input, and it should be loud.
There is no eval and no Function constructor anywhere in the package - asserted against the source in the unit
suite and against all three built bundles in npm run test:dist. Bindings therefore work under
script-src 'self' without unsafe-eval.
Thirty-three names. Anything not listed here is an internal detail and may change without a major version bump.
State
| Name | Signature |
|---|---|
observable |
(initial, {equals?}) → {value, peek(), set(v), subscribe(fn), extend(spec)} |
observableArray |
(initial?, {equals?}) → {value, length, peek(), set(a), remove(valueOrTest), removeAll(), indexOf(v), replace(old, new), destroy(valueOrTest), destroyAll(), subscribe(fn), extend(spec), …mutators} |
isEqual |
(a, b) → boolean - the deep comparison the change gate uses |
Graph
| Name | Signature |
|---|---|
computed |
(fn | {read, write}, {label?}) → Computation - read via .value or .get(); writable only with write |
effect |
(fn, {label?}) → Computation - runs immediately; .dispose() to stop |
untracked |
(fn) → any - run fn with dependency collection suspended |
flushSync |
() → void - drain the pending queue now, synchronously |
Dep |
class - one reactive slot; track(), trigger() |
DepMap |
class - lazily-populated keyed collection of Deps |
Computation |
class - the node type behind computed and effect |
trackingProxy |
(target, depFor, {onSet?}) → Proxy - wrap a host store's data so reads are tracked |
Bindings
| Name | Signature |
|---|---|
compile |
(template, data, container, renderFn?, options?) → controller |
applyBindings |
(data, rootElement) → handle |
annotate |
(template, options?) → {annotated, bindings} - string-only; no DOM needed |
scanBlocks |
(template) → block records - string-only |
TemplateCompiler |
the above grouped as an object, plus resolvePath |
registerBinding |
(name, handler) → handler |
unregisterBinding |
(name) → boolean |
registerExtender |
(name, fn) → fn - throws on a bad name |
unregisterExtender |
(name) → boolean - refuses the built-ins |
registerComponent |
(name, {template, create?}) → definition - throws on a bad definition |
unregisterComponent |
(name) → boolean |
createRootContext |
(data) → context |
createChildContext |
(parent, data, index?, length?) → context |
Expressions and rendering
| Name | Signature |
|---|---|
parseExpression |
(source, options?) → AST | null |
evaluateAst |
(ast, context) → any |
evaluateExpression |
(source, context, options?) → any |
compileExpression |
(source, options?) → (context) => any | null |
expressionDependencies |
(sourceOrAst, options?) → Set<string> |
registerHelper |
(name, fn) → fn - throws on a bad name |
unregisterHelper |
(name) → boolean |
clearExpressionCache |
() → number - entries dropped |
renderTemplate |
(template, data, {partials?}) → string |
The concepts map closely; the spellings do not. Nothing here is a drop-in replacement, and the differences are deliberate rather than incidental.
State and the graph
| Knockout | domma-reactive |
|---|---|
ko.observable(1) - read o(), write o(2) |
observable(1) - read o.value, write o.value = 2 |
ko.observableArray([]) |
observableArray([]) - remove() takes a value or a test, as Knockout's does |
.push .pop .shift .unshift .splice .sort .reverse |
identical |
.remove .removeAll .indexOf .replace .destroy .destroyAll |
identical |
ko.computed(fn) / ko.pureComputed(fn) |
computed(fn) - always lazy |
ko.computed({read, write}) |
computed({read, write}) |
.extend({rateLimit: 300}) |
.extend({rateLimit: 300}) - also throttle, notify: 'always' |
ko.extenders.mine = … |
registerExtender('mine', fn) |
o.peek() |
o.peek() |
ko.ignoreDependencies(fn) |
untracked(fn) |
sub.dispose() |
off() or off.dispose() |
ko.utils.unwrapObservable(x) |
none - read .value explicitly |
Bindings
| Knockout | domma-reactive |
|---|---|
text: name |
data-bind-text="name" |
css: {on: isActive} |
data-bind-class="isActive && 'on'" |
style: {color: shade} |
data-bind-style-color="shade", or data-bind-style="look" |
attr: {href: url} |
data-bind-href="url" |
value: query / textInput: query |
data-model="query" |
checked: done |
data-model="done" |
enable: x / disable: x |
data-bind-disabled="!x" / data-bind-disabled="x" |
visible: x |
data-bind-hidden="!x" |
hasFocus: editing |
data-focus="editing" |
options: xs, optionsText: 'name' |
data-options="xs" data-options-text="name" |
selectedOptions: chosen |
data-model="chosen" on a <select multiple> |
click: save / event: {…} |
data-on-click="save" / data-on-<event> |
if: isOpen / ifnot: isOpen |
data-if="isOpen" / data-if="!isOpen" |
foreach: rows |
data-each="rows key=id", or {{#each rows key=id}} |
with: obj |
{{#with obj}} |
<!-- ko if: x --> … <!-- /ko --> |
<!-- dm if: x --> … <!-- /dm --> |
ko.bindingHandlers.mine = … |
registerBinding('mine', handler) |
ko.applyBindings(vm, el) |
applyBindings(vm, el) |
ko.cleanNode(el) |
handle.dispose() |
$data $root $parent $index |
identical |
$parents[2] |
identical |
$parentContext |
identical |
$component |
identical |
ko.components.register('x', …) |
registerComponent('x', {template, create}) |
component: {name: 'x', params: {a: b}} |
data-component="'x'" data-param-a="b" |
component: {name: current} |
data-component="current.value" - swaps when it changes |
params.a in a view model |
identical - and params is frozen |
viewModel / createViewModel |
create(params, {element}) - one factory, no new |
$componentTemplateNodes |
{{#slot}} in the template, data-slot at the usage site |
html: markup |
none - {{{triple-stache}}}, which says so where you can see it |
The four differences worth knowing before you start:
- Reads are properties, not calls.
o.value, nevero(). That is what lets a template read an observable at all, since the expression language refuses method calls. key=is how lists reconcile. Knockout'sforeachdiffs by identity automatically; here you name the key, anddata-eachinsists on one.- No
unsafe-evalrequired. Knockout compiles binding strings with theFunctionconstructor, which a strict Content Security Policy blocks outright. This parses them instead. Plain object literals parse ({color: shade}), butstyleandoptionsare still spelled with companion attributes, which read more clearly one property at a time. - An observable is not its value. Knockout unwraps
if: show; heredata-if="show"names the observable object, so it warns and fails closed. Writeshow.value.
Every one of these was hit while building the example app above.
| Symptom | Cause | Fix |
|---|---|---|
| Ticking a checkbox changes nothing | A plain field on a list item is not reactive | done: observable(false), and bind done.value |
{{name}} renders literally |
applyBindings never interpolates mustache |
data-bind-text="name" |
data-each renders nothing, with a warning |
No key= |
data-each="rows key=id" |
data-if="show" never shows, with a warning naming show.value |
show is the observable, not its value |
data-if="show.value" |
<!-- dm if --> content missing from every row of a virtual list |
Virtual bindings are not read inside a list body | {{#if flag}} inside the body |
{{total.get()}} will not parse |
An expression cannot call a method | total.value, which is the same read |
| A binding is silently skipped | Its expression did not parse; look for the warning | The warning names the source and the template |
| Effects keep running after the DOM is gone | Nothing disposed them | handle.dispose() / controller.destroy() |
| Mutating an object and reassigning it does nothing | The change gate compares old and new - the same reference | Produce a new value |
data-model="$parents[0]" warns and does nothing |
$parents and every context are frozen |
Bind ancestor data: $parents[1].name |
A nested data-each renders its template unexpanded, with a warning |
A list's item template is compiled markup, and the compiler knows {{#each}}, not data-each |
Nest with {{#each}}, which works inside a data-each body |
data-component="contact-card" warns that the name is not a string |
Every binding value is an expression, so that reads a variable called contact-card |
Quote the literal: data-component="'contact-card'" |
A component's every param is undefined |
data-param-* with no data-component on the same element - look for the compile-time warning |
Check the component attribute is present and spelled correctly |
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 broken binding does not take the rest of the page down with it.
This is a reactivity and binding layer. It is not a framework: there is no router, no lifecycle hooks, no
server-side-rendering hydration beyond applyBindings, and no devtools.
Deliberate omissions, each with its reasoning above: no scope-chain lookup, no data-bind-html, no observable
unwrapping, and no eval-backed expressions. None of these is waiting on anything, and
the spellings here differ from Knockout's on purpose and will go on differing.
One thing is a refinement rather than an omission: list placement is in order rather than minimal-move. That costs extra DOM moves on a reverse or a long drag and nothing else - correctness and node identity do not depend on it, so it can land in any 1.x release without disturbing anything.
The Knockout gap list is empty. $parents[n] shipped in 0.6.0 with $parentContext, components in
0.7.0, and slots in 0.8.0. Anything Knockout can do, this can do. What remains different is spelling, and that
is settled rather than pending - the differences above are the price of parsing binding expressions by hand instead of
compiling them with Function, and they are not going to change.
The one thing beyond parity that is not here is scoped slots - a component exposing values its own slot content
can read, as Vue's v-slot does. That one genuinely does meet the compile-once wall: the content would have to be
compiled against a context that does not exist until mount. Knockout has no equivalent, so nothing is owed here.
npm test # watch
npm run test:run # once - 996 tests, including the finished app from Tutorial.md
npm run build # dist/
npm run test:dist # verify all 33 exports through require(), import() and <script>MIT.