Every interface keeps two things in step: the data, and the pixels showing it. Without bindings you write that second job by hand, once per element, and again every time either side changes.
function render() {
$('#name').text(user.name);
$('#email').text(user.email);
$('#badge').toggleClass('vip', user.vip);
$('#avatar').attr('src', user.avatar);
}
// …and remember to call it. Every time.
// Miss one and the page quietly lies.
<b data-bind-text="name"></b> <span data-bind-text="email"></span> <span data-bind-class="vip && 'vip'"></span> <img data-bind-src="avatar"> <!-- no render(), no call sites -->
The difference is not the character count. It is that the second version cannot fall out of step, because there is no step to miss.
Two ways in, depending on who owns the markup. They share every binding, every expression and the same list reconciler, so anything you learn about one applies to the other.
| Owns the markup | Entry point | You also get |
|---|---|---|
| A component — it has a template | Domma.component() |
{{ }}, {{#if}}, {{#each}}, lifecycle hooks |
| A page — the HTML already exists | M.applyBindings() |
No build step, no second source of truth for the markup |
// The component route — Domma generates the markup
Domma.component('user-card', {
template: `<b data-bind-text="name"></b>`,
data() { return { name: 'Ada' }; }
});
// The page route — the markup is already there
M.applyBindings(model, '#app');
Most of this page uses the component route, because it keeps each demo self-contained. Binding a page you already have shows the other one, on real markup sitting in this document. The engine behind both is published standalone as domma-reactive.
Five come from mustache syntax, four from data-* attributes.
| Write | What it does |
|---|---|
{{name}} | Text, into a generated anchor span |
class="{{cls}} card" | An attribute, interpolated |
{{#if x}}…{{/if}} | A re-rendered region |
{{{html}}} | A re-rendered region, unescaped |
{{#each xs key=id}}…{{/each}} | Reconciles — see below |
data-bind-text="expr" | A property, an attribute or a class |
data-model="path" | Two-way, control ↔ data |
data-on-click="save" | Any DOM event |
data-if="expr" | In the DOM, or not in it |
Every value on the right is an expression, not just a property name — see Expressions.
The suffix after data-bind- is the target. text writes
textContent; class adds and removes only the tokens this binding
applied last time, so static classes survive; anything else is a property when the DOM has one
and an attribute otherwise.
<b data-bind-text="label"></b> <span class="badge" data-bind-class="urgent && 'badge-danger'"></span> <button data-bind-disabled="!label">Send</button> <div data-bind-aria-hidden="!urgent"></div>
For an attribute, false, null and undefined
remove it, and true sets it to the empty string — so
data-bind-aria-hidden="collapsed" does what you meant rather than rendering the string
"false".
data-model
Type in the box; the data changes, and everything derived from it follows. There is no
onChange handler in this demo, and no call to re-render.
<input data-model="query">
<p>Searching for <b>{{query}}</b> ({{query.length}} characters)</p>
The expression must be a settable path — a name, or a member chain ending in one — because a binding you cannot write back through is not two-way. A comparison or a helper call is refused at compile time with a warning rather than silently going one-way.
Checkboxes bind checked, multi-selects bind an array, and number inputs coerce to a
Number — with an empty box becoming null rather than NaN. The
data → DOM direction writes only when the value actually differs, so a re-render while someone is
typing does not jump their caret to the end.
data-on- takes any DOM event name. The value is a method on your component, either as a
bare reference or as a call — and your declared arguments come first, with
the event always last, so a handler that wants only the event and one that wants
arguments are spelled the same way round.
<button data-on-click="bump">+1</button> <button data-on-click="add(5)">+5</button> <input data-on-keyup="onKey">
Returning false calls preventDefault() — the same idiom Domma's
.on() uses. Event bindings declare no dependencies: the listener is attached once and
reads the data when it fires, so there is nothing for an effect to re-run.
data-if means the element is in the document, or it is not. It is not hidden with CSS: a
binding named after a conditional that left the element focusable and read aloud by a screen reader
would be lying about what it does. Use data-bind-hidden when you want it present but
invisible.
<p data-if="items.length === 0">Nothing yet.</p>
<p data-bind-hidden="items.length === 0">{{items.length}} item(s)</p>
Truthiness is mustache truthiness, so an empty array is falsy — which means
{{#if items}} and data-if="items" can never disagree about an empty list.
This is the one worth understanding. {{#each items key=id}} reconciles:
a row that is still in the list after a change keeps the very same DOM nodes it had before. Not
equivalent nodes — the same objects.
Which matters because a node carries state the data never sees: keyboard focus, a half-typed value, scroll position, a running CSS transition, a playing video. Rebuild the node and all of it is gone.
Try it. Type into one of the boxes without pressing anything else, then press Prepend. The row moves down the page and your text is still there, still focused. The count underneath is measured, not claimed: it reports how many of the original row elements are still the identical object after the change.
{{#each rows key=id}}
<li><input data-model="note"> {{name}}</li>
{{/each}}
key= must name an identity, not a value. A key that changes when the row's
contents change defeats the whole mechanism — every row looks new, and you are back to rebuilding.
Without key= the block still works, but re-renders wholesale and says so once.
Everything above, together, in the shape of the smallest application anyone actually writes: add, tick off, delete, and a summary that cannot go stale.
<input data-model="draft" data-on-keyup="onKey">
<button data-on-click="add">Add</button>
{{#each todos key=id}}
<li>
<input type="checkbox" data-on-change="$parent.toggle($data)">
<span data-bind-class="done && 'bind-struck'">{{title}}</span>
<button data-on-click="$parent.remove($data)">×</button>
</li>
{{/each}}
<p data-if="todos.length === 0">Nothing to do.</p>
<p>{{summary}}</p>
Read that handler carefully: $parent.remove($data). Inside a list a bare name
resolves against the row — there is no scope walk, deliberately, because a name that
silently resolved one level up would mean something different depending on data you are not
looking at. So $parent names the component and $data is the row handing
itself over. $index, $length and $root are there
for the same reason.
A binding value is a small expression language: paths and indexing, string and number literals,
+ - * / %, === !== < <= > >=,
&& || ! with short-circuiting, ternaries, and calls to helpers you registered.
Precedence is JavaScript's.
<b data-bind-text="count > 0 ? 'in stock' : 'sold out'"></b>
<i data-bind-class="overdue && 'text-danger'"></i>
<span>{{ price * qty }}</span>
It is parsed by hand — a tokeniser, a Pratt parser and a tree-walking evaluator — rather than
compiled with the Function constructor. That is not stylistic. It means bindings work
under a strict Content Security Policy (script-src 'self', no
unsafe-eval), which is the setting where evaluating binding strings at runtime stops
working.
Refused on purpose: assignment, new, and reads of __proto__,
constructor or prototype in any form — including
a[key] where key only holds one of them at runtime. Method calls are
refused too, except in data-on-*, where invoking a method is the entire point and the
call happens on a gesture rather than during a render.
Everything above lives inside a component, which owns its template. M.applyBindings() is
the other direction: point it at markup that is already in the document — server-rendered,
hand-written, whatever — and it activates the binding attributes in place, leaving the markup
otherwise as it found it.
The panel below is not a component. It is plain HTML written directly into this page, brought to life by one call. View source and you will find it exactly as shown.
Greeting: rendered by the server
rendered by the server
Ready
// A helper, so the markup can shape a value without calling a method
M.registerHelper('greet', (n) => n ? `Hello, ${n}` : 'Hello');
// A custom binding kind, registered the same way every built-in is
M.registerBinding('uppercase', {
attribute: 'data-uppercase',
expression: true, tracks: true, primes: true,
update({binding, nodes, context}) {
const value = binding.evaluate(context);
for (const el of nodes) el.textContent = String(value).toUpperCase();
return true;
}
});
const model = M.create({
name: {type: M.types.string},
rows: {type: M.types.array}
}, {name: 'Ada', rows: [{id: 1, label: 'First row'}]});
const handle = M.applyBindings(model, '#apply-demo', {
methods: {
add() { /* push a row */ },
drop(row) { /* remove that row — called as $parent.drop($data) */ },
reset() { /* back to the start */ }
}
});
A Model is the ergonomic thing to pass: reads track it and writes route back through
set(), so data-model lands in the model with validation and change
notification intact — not in a throwaway copy. A plain object works too, but only the parts of it that
are observable will be reactive.
| Difference from a component | Why |
|---|---|
{{ }} is not interpolated |
Either the server already rendered the value, or the page was broken until JavaScript ran.
Use data-bind-text, which the server can render alongside the text.
|
data-if keeps the same node |
It detaches and restores the element, so children, listeners and focus survive a toggle. A template re-renders its region instead. |
key= is required on data-each |
There is no template to fall back to re-rendering — the markup is the page. |
Handlers arrive in options.methods |
A Model holds data, not behaviour. In a component they are the methods block. |
applyBindings returns a handle. Call handle.dispose() on anything that
outlives its markup — a router view, a modal, a slideover — because an effect is a live node in the
dependency graph and does not go away just because its nodes did.
$('#el').text(v) is clearer than a binding. Bindings earn their keep when something
changes more than once.
data-bind-html, deliberately. Assigning
innerHTML from data is the shortest route to a cross-site scripting hole. The
template already has an explicit, greppable opt-out — {{{triple-stache}}} — which
says so where a reviewer can see it.
M.observable(), M.computed() and M.effect() —
Models for schemas, validation and persistence, and
Components for the element lifecycle these templates live in.