Template Bindings
Say in the markup which piece of data each part of the page shows. Domma updates exactly those parts, and leaves the rest of the DOM - and everything the visitor was doing in it - alone.
The code you stop writing
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.
By hand
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.
Declared once
<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.
Where bindings live
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.
The binding kinds
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.
Text, classes and attributes
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.
<div class="bind-demo mb-4" id="oneway-host"></div>/**
* Text, classes and attributes - from public/showcase/models/bindings.
*/
export function mount(root) {
Domma.component('bind-oneway', {
template: `
<div class="mb-3">
<label class="form-label">Label</label>
<input class="form-input" data-model="label" placeholder="type something…">
</div>
<label class="mb-3 block">
<input type="checkbox" data-on-change="toggleUrgent"> Urgent
</label>
<p class="mb-2">Text: <b data-bind-text="label"></b></p>
<p class="mb-2">
Class: <span class="badge" data-bind-class="urgent && 'badge-danger'">badge</span>
</p>
<p class="mb-0">
Property: <button class="btn btn-sm btn-primary" data-bind-disabled="!label">Send</button>
</p>`,
data() { return {label: 'Ada', urgent: false}; },
methods: {
toggleUrgent() { this.set({urgent: !this.data.urgent}); }
}
});
$(root).find('#oneway-host').append(document.createElement('bind-oneway'));
}<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".
Two-way with 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.
<div class="bind-demo mb-4" id="model-host"></div>/**
* Two-way with data-model - from public/showcase/models/bindings.
*/
export function mount(root) {
// ── Two-way ───────────────────────────────────────────────────────────
Domma.component('bind-model', {
template: `
<div class="mb-3">
<label class="form-label">Search</label>
<input class="form-input" data-model="query" placeholder="type to search…">
</div>
<p class="mb-0">
Searching for <b data-bind-text="query || '(nothing)'"></b>
- <span data-bind-text="query.length"></span> character(s)
</p>`,
data() { return {query: ''}; }
});
$(root).find('#model-host').append(document.createElement('bind-model'));
}<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.
Events
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.
<div class="bind-demo mb-4" id="events-host"></div>/**
* Events - from public/showcase/models/bindings.
*/
export function mount(root) {
// ── Events ────────────────────────────────────────────────────────────
Domma.component('bind-events', {
template: `
<p class="mb-3">Count: <b data-bind-text="count"></b></p>
<div class="flex gap-2 flex-wrap">
<button class="btn btn-sm btn-primary" data-on-click="bump">+1</button>
<button class="btn btn-sm btn-outline" data-on-click="add(5)">+5</button>
<button class="btn btn-sm btn-outline" data-on-click="reset">Reset</button>
</div>
<p class="bind-stamp mt-3 mb-0" data-bind-text="last"></p>`,
data() { return {count: 0, last: 'nothing yet'}; },
methods: {
bump(event) { this.add(1, event); },
add(n, event) {
this.set({
count: this.data.count + n,
last: `${event ? event.type : 'call'} → +${n} at ${D().format('HH:mm:ss')}`
});
},
reset() { this.set({count: 0, last: 'reset'}); }
}
});
$(root).find('#events-host').append(document.createElement('bind-events'));
}<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.
Conditionals
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.
<div class="bind-demo mb-4" id="if-host"></div>/**
* Conditionals - from public/showcase/models/bindings.
*/
export function mount(root) {
// ── Conditionals ──────────────────────────────────────────────────────
Domma.component('bind-if', {
template: `
<div class="flex gap-2 mb-3">
<button class="btn btn-sm btn-primary" data-on-click="addOne">Add an item</button>
<button class="btn btn-sm btn-outline" data-on-click="clear">Clear</button>
</div>
<p class="mb-0" data-if="isEmpty">Nothing yet.</p>
<p class="mb-0" data-bind-hidden="isEmpty"><b data-bind-text="count"></b> item(s)</p>`,
data() { return {count: 0}; },
computed: {
isEmpty() { return this.data.count === 0; }
},
methods: {
addOne() { this.set({count: this.data.count + 1}); },
clear() { this.set({count: 0}); }
}
});
$(root).find('#if-host').append(document.createElement('bind-if'));
}<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.
Keyed lists keep the DOM you were using
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.
<div class="bind-demo mb-4" id="keyed-host"></div>/**
* Keyed lists keep the DOM you were using - from public/showcase/models/bindings.
*/
export function mount(root) {
// ── Keyed lists ───────────────────────────────────────────────────────
Domma.component('bind-keyed', {
template: `
<div class="flex gap-2 flex-wrap mb-3">
<button class="btn btn-sm btn-primary" data-on-click="prepend">Prepend</button>
<button class="btn btn-sm btn-outline" data-on-click="shuffle">Reverse</button>
<button class="btn btn-sm btn-outline" data-on-click="dropFirst">Remove first</button>
</div>
<div id="keyed-rows">
{{#each rows key=id}}
<div class="bind-row">
<span class="bind-tag">{{id}}</span>
<input class="form-input" data-model="note" placeholder="type here, then Prepend…">
<span>{{name}}</span>
</div>
{{/each}}
</div>
<p class="bind-stamp mt-3 mb-0" data-bind-text="identity"></p>`,
data() {
return {
nextId: 4,
identity: 'no change yet',
rows: [
{id: 1, name: 'first', note: ''},
{id: 2, name: 'second', note: ''},
{id: 3, name: 'third', note: ''}
]
};
},
methods: {
// Measured rather than claimed: hold the actual node objects, make
// the change, then count how many are still the identical object.
_snapshot() {
return Array.from(this.root.querySelectorAll('.bind-row'));
},
_report(before) {
const after = this._snapshot();
const kept = before.filter((node) => after.includes(node)).length;
this.set({
identity: `${kept} of ${before.length} original row element(s) survived - same objects, not rebuilt`
});
},
prepend() {
const before = this._snapshot();
const id = this.data.nextId;
this.set({
nextId: id + 1,
rows: [{id, name: 'new', note: ''}, ...this.data.rows]
});
setTimeout(() => this._report(before), 0);
},
shuffle() {
const before = this._snapshot();
this.set({rows: [...this.data.rows].reverse()});
setTimeout(() => this._report(before), 0);
},
dropFirst() {
const before = this._snapshot();
this.set({rows: this.data.rows.slice(1)});
setTimeout(() => this._report(before), 0);
}
}
});
$(root).find('#keyed-host').append(document.createElement('bind-keyed'));
}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.
A whole small app
Everything above, together, in the shape of the smallest application anyone actually writes: add, tick off, delete, and a summary that cannot go stale.
<div class="bind-demo mb-4" id="todo-host"></div>/**
* A whole small app - from public/showcase/models/bindings.
*/
export function mount(root) {
// ── A whole small app ─────────────────────────────────────────────────
Domma.component('bind-todo', {
template: `
<div class="flex gap-2 mb-3">
<input class="form-input" data-model="draft" data-on-keyup="onKey"
placeholder="What needs doing?">
<button class="btn btn-sm btn-primary" data-on-click="add">Add</button>
</div>
<div>
{{#each todos key=id}}
<div class="bind-row">
<input type="checkbox" data-on-change="$parent.toggle($data)">
<span data-bind-class="done && 'bind-struck'">{{title}}</span>
<button class="btn btn-sm btn-outline" data-on-click="$parent.remove($data)">×</button>
</div>
{{/each}}
</div>
<p class="mt-3 mb-0" data-if="isEmpty">Nothing to do.</p>
<p class="mt-3 mb-0" data-bind-hidden="isEmpty" data-bind-text="summary"></p>`,
data() {
return {nextId: 1, draft: '', todos: []};
},
computed: {
isEmpty() { return this.data.todos.length === 0; },
summary() {
const all = this.data.todos;
return `${all.filter((t) => !t.done).length} of ${all.length} left`;
}
},
methods: {
onKey(event) {
if (event.key === 'Enter') this.add();
},
add() {
const title = (this.data.draft || '').trim();
if (!title) return;
const id = this.data.nextId;
this.set({
nextId: id + 1,
draft: '',
todos: [...this.data.todos, {id, title, done: false}]
});
},
toggle(item) {
this.set({
todos: this.data.todos.map(
(t) => (t.id === item.id ? {...t, done: !t.done} : t)
)
});
},
remove(item) {
this.set({todos: this.data.todos.filter((t) => t.id !== item.id)});
}
}
});
$(root).find('#todo-host').append(document.createElement('bind-todo'));
}<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.
Expressions, and the reason they are hand-parsed
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.
Binding a page you already have
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
<div class="bind-demo mb-4" id="apply-demo">
<div class="mb-3">
<label class="form-label">Name</label>
<input class="form-input" data-model="name" placeholder="type a name…">
</div>
<p class="mb-2">Greeting: <b data-bind-text="greet(name)">rendered by the server</b></p>
<p class="mb-3" data-uppercase="name">rendered by the server</p>
<p class="mb-3" data-if="name">
<span class="badge badge-success">Ready</span>
</p>
<ul class="mb-3" data-each="rows key=id">
<li>
<span data-bind-text="label"></span>
<!-- Inside a list a bare name is looked for on the ITEM, so a row
reaches the handler that owns it through $parent. -->
<button type="button" class="btn btn-sm" data-on-click="$parent.drop($data)">remove</button>
</li>
</ul>
<button type="button" class="btn btn-primary btn-sm" data-on-click="add">Add a row</button>
<button type="button" class="btn btn-sm" data-on-click="reset">Reset</button>
</div>/**
* Binding a page you already have - from public/showcase/models/bindings.
*/
export function mount(root) {
// ── The page route: M.applyBindings on markup already in this document ──
//
// Nothing below creates any HTML. #apply-demo is written out in full in the
// section above; this only brings it to life.
// A helper, so an expression can shape a value without calling a method.
M.registerHelper('greet', (n) => (n ? `Hello, ${n}` : 'Hello'));
// A custom binding kind, registered through the same function every
// built-in uses.
M.registerBinding('uppercase', {
attribute: 'data-uppercase',
expression: true, // parse the attribute value
tracks: true, // re-run when what it reads changes
primes: true, // run once after the first paint
update({binding, nodes, context}) {
const value = binding.evaluate(context);
for (const el of nodes) el.textContent = String(value).toUpperCase();
return true;
}
});
const applyModel = M.create({
name: {type: M.types.string},
rows: {type: M.types.array}
}, {
name: 'Ada',
rows: [{id: 1, label: 'First row'}, {id: 2, label: 'Second row'}]
});
let nextRowId = 3;
M.applyBindings(applyModel, '#apply-demo', {
methods: {
add() {
const id = nextRowId++;
applyModel.set('rows', [...applyModel.get('rows'), {id, label: `Row ${id}`}]);
},
// A row's handler is given the item, via $data on the button.
drop(row) {
applyModel.set('rows', applyModel.get('rows').filter((r) => r.id !== row.id));
},
reset() {
nextRowId = 3;
applyModel.set({
name: 'Ada',
rows: [{id: 1, label: 'First row'}, {id: 2, label: 'Second row'}]
});
}
}
});
}// 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.
Reaching past one level
$parent is one step out, and for a long time it was the only step there was: from inside a list inside a list, two levels up could not be reached at all.
$parents is the whole chain, nearest first - $parents[0] is $parent, $parents[1] is a grandparent. And $parentContext is the enclosing context rather than its data, which is the only way to ask a question the data cannot answer: which row of the outer list am I in? Position lives on the context, not on the item.
-
-
{{#each members key=id}}
- {{name}} - on {{$parents[0].name}}, in {{$parents[1].season}}, listed {{$parentContext.$index}} places down {{/each}}
<div class="bind-demo mb-4" id="ancestors-demo">
<ul class="mb-3" data-each="teams key=id">
<li class="mb-3">
<b data-bind-text="name"></b>
<ol class="mb-0">
{{#each members key=id}}
<li>
{{name}} - on {{$parents[0].name}}, in {{$parents[1].season}}, listed
{{$parentContext.$index}} places down
</li>
{{/each}}
</ol>
</li>
</ul>
<button type="button" class="btn btn-primary btn-sm" data-on-click="addTeam">Add a team</button>
</div>/**
* Reaching past one level - from public/showcase/models/bindings.
*/
export function mount(root) {
// ── Ancestors: $parents and $parentContext, on a list inside a list ────
//
// The inner list is {{#each}}, not a second data-each: an item template is
// compiled markup, and the compiler discovers lists from {{#each}} only.
const roster = () => [
{id: 1, name: 'Red team', members: [{id: 11, name: 'Ada'}, {id: 12, name: 'Grace'}]},
{id: 2, name: 'Blue team', members: [{id: 21, name: 'Alan'}, {id: 22, name: 'Edsger'}]}
];
const ancestorsModel = M.create({
season: {type: M.types.string},
teams: {type: M.types.array}
}, {season: 'the summer league', teams: roster()});
M.applyBindings(ancestorsModel, '#ancestors-demo', {
methods: {
// Appends. Deliberately NOT a reorder: moving an outer row leaves
// $parentContext.$index reading the old position - see the note in
// the section above. Appending leaves every existing index alone.
addTeam() {
const teams = ancestorsModel.get('teams');
const id = teams.length + 1;
ancestorsModel.set('teams', [...teams, {
id,
name: `Team ${id}`,
members: [{id: id * 10 + 1, name: 'Someone new'}]
}]);
}
}
});
}Press it. The new team arrives with its position already resolved, and every member already listed keeps its team and its season - those are ancestor data, and the data did not change. The trailing number is the outer list's $index, which is the one fact about a member that lives nowhere in the member.
<ul data-each="teams key=id">
<li>
<b data-bind-text="name"></b>
<ol>
{{#each members key=id}}
<li>
{{name}}
- on {{$parents[0].name}} <!-- the team: same as $parent.name -->
- in {{$parents[1].season}} <!-- the root: nothing else reaches it -->
- {{$parentContext.$index}} <!-- the OUTER list's position -->
</li>
{{/each}}
</ol>
</li>
</ul>
Read the inner list carefully - it is {{#each}}, not a second data-each. An item template is compiled markup, and the compiler finds lists by looking for {{#each}}. A data-each nested inside one is left exactly as written: its contents render once, as ordinary markup, and every binding in them resolves against the outer item. It looks close enough to working to survive review, so activation now warns and names the form to write instead.
$parents costs nothing to have available: it is built only if a template asks for it, by walking the chain on first read. Both names are frozen, like every context - writing through $parents[0] warns and skips rather than throwing, which is the same thing a binding does with any path it cannot set.
Known limitation -
$parentContext.$indexdoes not survive a reorder. Appending to the outer list is fine, and so is anything that leaves existing positions alone. But moving an outer row - a sort, a reverse, a prepend - relocates the DOM correctly while leaving the inner items reading the position their group used to be in. The refresh gate compares the parent context by its$dataand$root, and a move changes neither, so no inner item is told anything happened. Ancestor data -$parents[n]and$parent- is correct throughout; it is only the enclosing position that goes stale. Fixed and awaiting adomma-reactiverelease.
When not to reach for it
- A one-off write. If a value is set once and never changes,
$('#el').text(v)is clearer than a binding. Bindings earn their keep when something changes more than once. - There is no
data-bind-html, deliberately. AssigninginnerHTMLfrom 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. - A list without a stable id. Reconciliation needs an identity. If your rows have no id, either give them one or accept the wholesale re-render; a key derived from the contents is worse than no key at all.
- Anything complicated. The expression language stops well short of JavaScript on purpose. If a binding needs more than the grammar above, it belongs in a computed, where it can be named, tested and read.