This is the tutorial from the package, running. Add someone, search, filter by group, edit a name in place, delete a row - then reload the page and find it all still there. Everything below builds this, a step at a time, and every listing is a region of the file this demo is running.
loading…
No contacts match.
Stored in this browser through localStorage, exactly as the tutorial writes it. Reload the page and your changes are still here.
What it teaches
| Step | Feature |
|---|---|
| 1 | observable, observableArray |
| 2 | applyBindings, data-each, keyed reconciliation |
| 3 | data-model, data-on-submit, computed for validation |
| 4 | data-options, data-options-caption |
| 5 | .extend({rateLimit}) |
| 6 | data-if, data-focus, per-item bindings |
| 7 | $parent, $data, observableArray.remove |
| 8 | virtual bindings - <!-- dm if: … --> |
| 9 | effect + localStorage |
| 10 | disposal |
| 11 | registerComponent, data-component, params |
| 12 | {{#slot}}, data-slot, projected content |
Before you start
npm install domma-reactive
Two files next to each other, index.html and app.js, and a <script type="module" src="./app.js"> at the end of
the body. There is nothing else to set up - no bundler, no transform, no dev server.
One note on which package this is. domma-reactive is the reactive core on its own: observables, computeds,
bindings. It has no HTTP client, no storage helper and no toasts. Inside the full Domma framework you would reach for
S.set() rather than localStorage; here we use the platform directly, because that is all this package assumes.
Step 1 - the shape of a contact
Everything that can change is an observable; the collection is an observableArray.
const GROUPS = ['Family', 'Friends', 'Work'];
const KEY = 'domma-reactive:contacts';
let nextId = 1;
/**
* One contact. Everything that can change is an observable; `id` is a plain
* number, because it is the row's identity and an identity that can change is
* not one. `key=id` in the template rests on it being stable.
*/
const make = ({id, name = '', email = '', group = GROUPS[0]} = {}) => {
if (id !== undefined) nextId = Math.max(nextId, id + 1);
return {
id: id ?? nextId++,
name: observable(name),
email: observable(email),
group: observable(group)
};
};
const contacts = observableArray([]);
const groups = observable(GROUPS);Two things are worth pausing on.
id is a plain number, not an observable. It is the row's identity, and an identity that can change is not one.
Step 2 hands it to key=, and the whole of keyed reconciliation rests on it being stable.
Reads are properties, never calls. contact.name.value, not contact.name(). Coming from Knockout this is the
one thing to unlearn, and it is what lets a template read an observable at all - the expression language in a binding
refuses method calls.
Step 2 - show the list
<ul class="contacts-list" data-each="visible.value key=id">
<li>
<span data-bind-text="name.value"></span>
</li>
</ul>
applyBindings(vm, document.querySelector('#app'));
data-each lifts the <li> out as the item template, compiles it once, and clones it per contact. Inside it, $data
is the contact, so a bare name.value resolves against the row.
key=id is not optional. With it, deleting the second row leaves the first row's actual DOM node in place - its
focus, its scroll position, its half-typed input. Without it, applyBindings refuses the block and says so.
Step 3 - add a contact
<form class="contacts-new" data-on-submit="save">
<input data-model="draft.name.value" placeholder="Name">
<input data-model="draft.email.value" placeholder="Email">
<button data-bind-disabled="!valid.value">Add</button>
</form>
// The form's own state. A draft is not a contact until it is added, so it is
// three observables rather than a half-built row in the list.
const draft = {
name: observable(''),
email: observable(''),
group: observable(GROUPS[0])
};
const naming = observable(false);
const valid = computed(() =>
draft.name.value.trim() !== '' && draft.email.value.includes('@'));data-model is two-way with no change handler: type, and draft.name.value already holds what you typed. valid is
a computed over the two fields, and the button's disabled is a binding over valid - so there is no validate()
that can be called at the wrong moment, and no way for the button to disagree with the form.
The save handler returns false, which calls preventDefault(), which is why the form never navigates.
Step 4 - the two drop-downs
<select data-model="draft.group.value" data-options="groups.value"></select>
<select data-model="filter.value"
data-options="groups.value"
data-options-caption="'All groups'"></select>
data-options fills the <select> and - the part you cannot easily write yourself - puts the selection back
after a rebuild. Two spellings catch everybody once: hand it groups.value rather than groups, because the binding
wants the array; and quote a literal caption, "'All groups'", because every binding value is an expression, so a
bare All groups would be read as a variable.
Step 5 - search that waits for you to stop typing
// The search box announces 200ms after typing stops. The write is never
// delayed - only the notification - so the input keeps up with the keyboard
// while the list is not rebuilt on every keystroke.
const query = observable('').extend({rateLimit: 200});
const filter = observable('');That is the debounce. Note where it sits: on the observable, not on the input and not on the list. The write lands
immediately, so the box keeps up with the keyboard; only the notification waits. Knockout's original throttle
delayed the write, which is why reading a throttled observable used to give you a stale value - that mistake is not
repeated here.
Step 6 - edit in place, and what the list actually shows
/**
* What the list shows: the filter and the search applied to the contacts.
*
* It reads `contacts`, `query` and `filter`, so it recomputes when any of the
* three changes and never otherwise. Nothing tells the `<ul>` to re-render -
* `data-each="visible.value key=id"` reads this, and the reconciler keeps the
* DOM of every row that survives the filter.
*/
const visible = computed(() => {
const needle = query.value.trim().toLowerCase();
const group = filter.value;
return contacts.value.filter((contact) => {
if (group !== '' && contact.group.value !== group) return false;
if (needle === '') return true;
return contact.name.value.toLowerCase().includes(needle)
|| contact.email.value.toLowerCase().includes(needle);
});
});
const summary = computed(() =>
`${contacts.length} contact${contacts.length === 1 ? '' : 's'}, ${visible.value.length} shown`);
const empty = computed(() => visible.value.length === 0);visible is where filtering and searching happen, and it is the only place either happens. The <ul> reads it; the
summary line reads it; the empty state reads it. None of them can disagree, because none of them holds an opinion of
its own.
Editing a row is three lines, and they arrive in Step 11 - because whether a row is being edited is per-row state, and per-row state belongs to a component.
Step 7 - delete
<li>
<button data-on-click="$parent.remove($data)">Delete</button>
</li>
const vm = {
contacts, groups, query, filter, draft, naming, visible, valid, summary, empty,
/**
* A DOM event fires outside every effect, so an expression may call a
* method here and nowhere else. Returning false calls preventDefault(),
* which is why the form never navigates.
*/
save() {
if (!valid.value) return false;
contacts.push(make({
name: draft.name.value.trim(),
email: draft.email.value.trim(),
group: draft.group.value
}));
draft.name.value = '';
draft.email.value = '';
naming.value = true;
return false;
},
// Inside the list `$data` is the row, so a row reaches the list that owns
// it through `$parent` - `data-on-click="$parent.remove($data)"`.
remove(contact) { contacts.remove(contact); }
};Inside a list, $data is the item and a bare name resolves against it - so remove alone would be looked for on the
contact and not found. $parent is the enclosing data, which is the view model that owns the list. This is the one
place an expression may call a method, and the reason is worth knowing: an event fires on a gesture, outside every
effect, where a call is not a side effect during a read.
Step 8 - the empty state, with nothing to hang it on
A binding attribute needs an element. Sometimes there is not one to spare, and wrapping the content 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:
<p class="contacts-summary"><!-- dm text: summary.value -->loading…<!-- /dm --></p>
<!-- dm if: empty.value -->
<p class="contacts-none">No contacts match.</p>
<!-- /dm -->
Both are live on this page. This is Knockout's <!-- ko if: x --> … <!-- /ko -->, and it is the one thing
applyBindings genuinely could not otherwise express. Every closer is <!-- /dm -->, whatever it closes, and they
nest.
Step 9 - remember it
/**
* Persistence, as an effect.
*
* It reads the array, so it re-runs whenever the array changes - add, delete,
* or edit a name inside a row. There is nothing to subscribe to and nothing to
* remember to call.
*/
function remember() {
return effect(() => {
localStorage.setItem(KEY, JSON.stringify(contacts.value.map((contact) => ({
id: contact.id,
name: contact.name.value,
email: contact.email.value,
group: contact.group.value
}))));
});
}
/** Whatever was here last time, or three contacts to start with. */
function restore() {
let stored = [];
try {
stored = JSON.parse(localStorage.getItem(KEY) || '[]');
} catch {
stored = [];
}
const rows = stored.length ? stored : [
{name: 'Ada Lovelace', email: 'ada@example.com', group: 'Work'},
{name: 'Grace Hopper', email: 'grace@example.com', group: 'Work'},
{name: 'Mo Farah', email: 'mo@example.com', group: 'Friends'}
];
for (const row of rows) contacts.push(make(row));
}An effect is the subscription. It reads the array, so it re-runs whenever the array changes - and because it
also reads each contact's fields while mapping them, renaming somebody in place saves too. Nothing is subscribed to,
and nothing has to be remembered.
Swapping storage for a server is the same shape: read the same state, write it somewhere else. That is exactly what
the booking example does with a Domma CMS collection instead of localStorage:
effect(() => {
const current = query.value; // read synchronously - this is the dependency
H.post('/api/v1/contacts', {data: current});
});
Step 10 - tidying up
/**
* Start it. `applyBindings` returns a handle, and the handle disposes: on a
* page that swaps views rather than reloading, `handle.dispose()` drops every
* effect, listener and marker this created and leaves the markup as it found
* it. `saved.dispose()` stops the persistence effect.
*/
export function mount(element) {
restore();
const saved = remember();
const handle = applyBindings(vm, element);
return () => {
handle.dispose();
saved.dispose();
};
}| Created by | Torn down by |
|---|---|
effect(fn) |
.dispose() on what it returned |
applyBindings(…) |
handle.dispose() |
compile(…) |
controller.destroy() |
observable.subscribe(fn) |
the returned off() |
On a page that lives until the tab closes this is academic. In an application that swaps views, skipping it is a leak that is invisible in the DOM: the markup looks right while the graph grows without bound and effects go on recomputing against nodes no document contains.
Step 11 - make the row a component
/**
* The row, as a component.
*
* `editing` belongs to the row - twenty contacts have twenty of them, and the
* page has no "currently editing" field to keep in step with the DOM. The
* contact itself arrives as a param by reference (`data-param-contact="$data"`
* passes the object, not a copy), so an edit made in here is an edit the page
* sees.
*/
registerComponent('contact-row', {
template: `
<span class="show" data-if="!editing.value">{{contact.name.value}} - {{contact.email.value}}</span>
<input class="edit form-input" data-if="editing.value"
data-model="contact.name.value" data-focus="editing.value">
<span class="chip" data-bind-text="contact.group.value"></span>
<button class="btn btn-sm" data-on-click="edit" data-bind-text="label.value"></button>
{{#slot actions}}{{/slot}}`,
create({contact}) {
const editing = observable(false);
return {
contact,
editing,
label: computed(() => (editing.value ? 'Done' : 'Edit')),
edit() { editing.value = !editing.value; }
};
}
});<ul class="contacts-list" data-each="visible.value key=id">
<li data-component="'contact-row'" data-param-contact="$data"></li>
</ul>
editing now belongs to the row. Twenty contacts have twenty of them, none can see the others, and the page has no
"currently editing" id to keep in step with the DOM.
Two spellings do real work there. data-component="'contact-row'" needs its inner quotes, because a binding value is
an expression and a bare contact-row reads a variable. And data-param-contact="$data" passes the contact
object, so an edit made inside the row is an edit the page sees - had it said data-param-name="name.value", the
component would have received a copy of a string and the write would have gone nowhere.
Step 12 - let the page supply the row's actions
The component ends with a hole:
{{#slot actions}}{{/slot}}
and the page fills it:
<li data-component="'contact-row'" data-param-contact="$data">
<button data-slot="actions" data-on-click="$parent.remove($data)">Delete</button>
</li>
The rule worth reading twice: projected content is compiled where it is written. That Delete button resolves
against the page's context, which is why $parent.remove($data) works inside it even though the component's view
model has never heard of remove. A component cannot inject values into its slot content, and does not need to - to
hand something outward, pass a callback param.
The finished thing
/**
* The finished contacts system from the tutorial.
*
* This is the file the tutorial builds, step by step, and it is the file
* running on the tutorial page - every listing there is a region of this
* module, so the code you read is the code you are using.
*
* It deliberately uses `localStorage` rather than Domma's `S` wrapper, and no
* HTTP client at all: domma-reactive on its own is observables, computeds and
* bindings, and this page is the proof that nothing else is needed. The
* examples elsewhere on this site show the same shapes talking to Domma CMS.
*/
import {
applyBindings, computed, effect, observable, observableArray, registerComponent
} from '../domma-reactive.esm.js';
const GROUPS = ['Family', 'Friends', 'Work'];
const KEY = 'domma-reactive:contacts';
let nextId = 1;
/**
* One contact. Everything that can change is an observable; `id` is a plain
* number, because it is the row's identity and an identity that can change is
* not one. `key=id` in the template rests on it being stable.
*/
const make = ({id, name = '', email = '', group = GROUPS[0]} = {}) => {
if (id !== undefined) nextId = Math.max(nextId, id + 1);
return {
id: id ?? nextId++,
name: observable(name),
email: observable(email),
group: observable(group)
};
};
const contacts = observableArray([]);
const groups = observable(GROUPS);
/**
* The row, as a component.
*
* `editing` belongs to the row - twenty contacts have twenty of them, and the
* page has no "currently editing" field to keep in step with the DOM. The
* contact itself arrives as a param by reference (`data-param-contact="$data"`
* passes the object, not a copy), so an edit made in here is an edit the page
* sees.
*/
registerComponent('contact-row', {
template: `
<span class="show" data-if="!editing.value">{{contact.name.value}} - {{contact.email.value}}</span>
<input class="edit form-input" data-if="editing.value"
data-model="contact.name.value" data-focus="editing.value">
<span class="chip" data-bind-text="contact.group.value"></span>
<button class="btn btn-sm" data-on-click="edit" data-bind-text="label.value"></button>
{{#slot actions}}{{/slot}}`,
create({contact}) {
const editing = observable(false);
return {
contact,
editing,
label: computed(() => (editing.value ? 'Done' : 'Edit')),
edit() { editing.value = !editing.value; }
};
}
});
// The form's own state. A draft is not a contact until it is added, so it is
// three observables rather than a half-built row in the list.
const draft = {
name: observable(''),
email: observable(''),
group: observable(GROUPS[0])
};
const naming = observable(false);
const valid = computed(() =>
draft.name.value.trim() !== '' && draft.email.value.includes('@'));
// The search box announces 200ms after typing stops. The write is never
// delayed - only the notification - so the input keeps up with the keyboard
// while the list is not rebuilt on every keystroke.
const query = observable('').extend({rateLimit: 200});
const filter = observable('');
/**
* What the list shows: the filter and the search applied to the contacts.
*
* It reads `contacts`, `query` and `filter`, so it recomputes when any of the
* three changes and never otherwise. Nothing tells the `<ul>` to re-render -
* `data-each="visible.value key=id"` reads this, and the reconciler keeps the
* DOM of every row that survives the filter.
*/
const visible = computed(() => {
const needle = query.value.trim().toLowerCase();
const group = filter.value;
return contacts.value.filter((contact) => {
if (group !== '' && contact.group.value !== group) return false;
if (needle === '') return true;
return contact.name.value.toLowerCase().includes(needle)
|| contact.email.value.toLowerCase().includes(needle);
});
});
const summary = computed(() =>
`${contacts.length} contact${contacts.length === 1 ? '' : 's'}, ${visible.value.length} shown`);
const empty = computed(() => visible.value.length === 0);
const vm = {
contacts, groups, query, filter, draft, naming, visible, valid, summary, empty,
/**
* A DOM event fires outside every effect, so an expression may call a
* method here and nowhere else. Returning false calls preventDefault(),
* which is why the form never navigates.
*/
save() {
if (!valid.value) return false;
contacts.push(make({
name: draft.name.value.trim(),
email: draft.email.value.trim(),
group: draft.group.value
}));
draft.name.value = '';
draft.email.value = '';
naming.value = true;
return false;
},
// Inside the list `$data` is the row, so a row reaches the list that owns
// it through `$parent` - `data-on-click="$parent.remove($data)"`.
remove(contact) { contacts.remove(contact); }
};
/**
* Persistence, as an effect.
*
* It reads the array, so it re-runs whenever the array changes - add, delete,
* or edit a name inside a row. There is nothing to subscribe to and nothing to
* remember to call.
*/
function remember() {
return effect(() => {
localStorage.setItem(KEY, JSON.stringify(contacts.value.map((contact) => ({
id: contact.id,
name: contact.name.value,
email: contact.email.value,
group: contact.group.value
}))));
});
}
/** Whatever was here last time, or three contacts to start with. */
function restore() {
let stored = [];
try {
stored = JSON.parse(localStorage.getItem(KEY) || '[]');
} catch {
stored = [];
}
const rows = stored.length ? stored : [
{name: 'Ada Lovelace', email: 'ada@example.com', group: 'Work'},
{name: 'Grace Hopper', email: 'grace@example.com', group: 'Work'},
{name: 'Mo Farah', email: 'mo@example.com', group: 'Friends'}
];
for (const row of rows) contacts.push(make(row));
}
/**
* Start it. `applyBindings` returns a handle, and the handle disposes: on a
* page that swaps views rather than reloading, `handle.dispose()` drops every
* effect, listener and marker this created and leaves the markup as it found
* it. `saved.dispose()` stops the persistence effect.
*/
export function mount(element) {
restore();
const saved = remember();
const handle = applyBindings(vm, element);
return () => {
handle.dispose();
saved.dispose();
};
}<div class="contacts">
<p class="contacts-summary"><!-- dm text: summary.value -->loading…<!-- /dm --></p>
<form class="contacts-new" data-on-submit="save">
<input class="form-input" data-model="draft.name.value" placeholder="Name" data-focus="naming.value">
<input class="form-input" data-model="draft.email.value" placeholder="Email">
<select class="form-select" data-model="draft.group.value" data-options="groups.value"></select>
<button class="btn btn-primary" data-bind-disabled="!valid.value">Add</button>
</form>
<div class="contacts-filters">
<input class="form-input" data-model="query.value" placeholder="Search name or email">
<select class="form-select" data-model="filter.value" data-options="groups.value" data-options-caption="'All groups'"></select>
</div>
<ul class="contacts-list" data-each="visible.value key=id">
<li data-component="'contact-row'" data-param-contact="$data">
<button class="btn btn-sm btn-ghost" data-slot="actions" data-on-click="$parent.remove($data)">Delete</button>
</li>
</ul>
<!-- dm if: empty.value -->
<p class="contacts-none">No contacts match.</p>
<!-- /dm -->
<p class="app-note">Stored in this browser through <code>localStorage</code>, exactly as the tutorial writes it. Reload the page and your changes are still here.</p>
</div>The tutorial in the package is transcribed into src/tutorial.test.js and runs on every npm test, so if a change to
the library breaks this page, something goes red before it ships.
Things that will catch you
| Symptom | Cause | Fix |
|---|---|---|
A field is empty, with a warning naming name.value |
Bound the observable, not its value (before 1.2 it showed [object Object]) |
data-model="name.value" |
data-if="show" never shows, with a warning |
show is the observable, not its value - since 1.2 it fails closed |
data-if="show.value" |
A <!-- dm if --> inside a virtual list is missing from every row |
Virtual bindings are not read inside a list body | {{#if flag}} inside the body |
| Ticking a checkbox changes nothing | The field on the item is not reactive | done: observable(false), bind done.value |
{{name}} appears literally |
applyBindings never interpolates mustache, except inside a data-each body |
data-bind-text="name.value" |
| The list renders nothing, with a warning | No key= |
data-each="rows.value key=id" |
data-options renders nothing |
Handed the observable rather than the array | data-options="groups.value" |
| The caption looks for a variable | A binding value is an expression | data-options-caption="'All groups'" |
{{total.get()}} will not parse |
An expression cannot call a method | total.value |
| An edit inside a card never reaches the page | The param passed a copy | data-param-contact="$data" |
Nothing in the binding layer throws on bad input. Every failure above logs exactly one warning naming the expression, and skips that binding alone.