Build a contacts system

Twelve steps, about 120 lines, and no build step at any point.

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.

app.js - the stateview whole file
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>
app.js - the draft, and whether it is validview whole file
// 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

app.js - the search boxview whole file
// 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

app.js - the derived stateview whole file
/**
 * 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>
app.js - the view modelview whole file
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&hellip;<!-- /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

app.js - persistenceview whole file
/**
 * 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

app.js - starting, and stoppingview whole file
/**
 * 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

app.js - the row componentview whole file
/**
 * 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

apps/contacts.jsview whole file
/**
 * 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();
    };
}
markup/contacts.htmlview whole file
<div class="contacts">

    <p class="contacts-summary"><!-- dm text: summary.value -->loading&hellip;<!-- /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.

Get it Look something up See it against a database