There is no build step, no compiler and no configuration file. Whichever route you take below, the finished state is the
same: the library is on the page, and applyBindings() or compile() is called once.
What it needs
| Browsers | Anything with ES2020 - optional chaining, globalThis, Proxy. No polyfills are shipped or required |
| Node | 18 or newer for the reactive core (observable, computed, effect, expressions, the renderer). No DOM needed |
| Bundler | None required. If you have one, nothing to configure |
| TypeScript | Declarations ship in the package - see Types |
Install it
By npm
npm install domma-reactive
import {observable, computed, effect, applyBindings} from 'domma-reactive'; // ESM
const {observable, applyBindings} = require('domma-reactive'); // CommonJS
The package declares exports, so both resolve to the right bundle without configuration:
| Condition | File |
|---|---|
import |
dist/domma-reactive.esm.js |
require() |
dist/domma-reactive.cjs |
<script> / the browser field |
dist/domma-reactive.min.js |
By script tag
The UMD bundle puts every export on a global called DommaReactive:
<script src="https://cdn.jsdelivr.net/npm/domma-reactive@1/dist/domma-reactive.min.js"></script>
<script>
const {observable, computed, applyBindings} = DommaReactive;
</script>
Pin the major version (@1) rather than floating, or pin exactly (@1.2.0) if you would rather approve every upgrade
yourself.
By module, without a bundler
<script type="module">
import {observable, applyBindings} from 'https://cdn.jsdelivr.net/npm/domma-reactive@1/+esm';
</script>
Or serve the file yourself - copy node_modules/domma-reactive/dist/domma-reactive.esm.js next to your own scripts and
import it by path. That is what this site does, so that its demos and its code listings are the same file.
What arrives
| File | Format | Size |
|---|---|---|
dist/domma-reactive.min.js |
UMD, minified | 65 KB, 21 KB gzipped |
dist/domma-reactive.cjs |
UMD | 65 KB |
dist/domma-reactive.esm.js |
ES module, comments intact | 336 KB - your bundler minifies it |
No dependencies, MIT.
Your first page
The library exists for HTML that already exists. Write the relationships into the markup, then activate them once:
<div id="app">
<input data-model="query.value" placeholder="Search">
<p data-bind-text="summary.value"></p>
<ul data-each="visible.value key=id">
<li data-bind-text="name"></li>
</ul>
<p data-if="empty.value">Nothing matches.</p>
</div>
import {observable, observableArray, computed, applyBindings} from 'domma-reactive';
const rows = observableArray([
{id: 1, name: 'Ada Lovelace'},
{id: 2, name: 'Grace Hopper'}
]);
const query = observable('');
const visible = computed(() => {
const needle = query.value.trim().toLowerCase();
return rows.value.filter(row => row.name.toLowerCase().includes(needle));
});
const app = {
rows,
query,
visible,
summary: computed(() => `${visible.value.length} of ${rows.length}`),
empty: computed(() => visible.value.length === 0)
};
const handle = applyBindings(app, document.querySelector('#app'));
That is a complete application. Three rules cover almost every early mistake:
.valueeverywhere. An observable is read and written through.value, in JavaScript and in a binding alike.key=is required on a list. It names the property that identifies an item, and it is what lets a row keep its DOM node.{{ }}is not interpolated byapplyBindings. Usedata-bind-text. Mustache works inside adata-eachbody, which is a template rather than rendered output, and inside a component's own template.
If you own the markup as a string
compile() is the same machinery pointed the other way - it renders a mustache template and binds it:
import {compile} from 'domma-reactive';
const controller = compile(
'<ul>{{#each rows key=id}}<li>{{name}}</li>{{/each}}</ul>',
{rows},
document.querySelector('#out'),
undefined,
{reactive: true} // one effect per binding - a standalone consumer wants this
);
{reactive: true} is off by default because Domma wires its own effects. Outside Domma, turn it on.
In a bundler
Nothing to configure. Vite, webpack, Rollup, esbuild and Parcel all resolve the package through its exports field and
tree-shake the ESM build normally.
// vite.config.js - no entry for domma-reactive is needed
export default {};
Two things occasionally worth knowing:
- The ESM bundle ships with its comments, which is why it is 336 KB on disk and about a fifth of that after your minifier has run. Do not serve it unminified in production.
- There is no side-effectful module init, so
sideEffects: falsetreatment is safe. Importing onlyobservablereally does leave the compiler out of your bundle.
In Node, or a worker
The reactive core needs no DOM:
import {observable, computed, effect, flushSync} from 'domma-reactive';
const price = observable(10);
const total = computed(() => price.value * 1.2);
effect(() => console.log(total.value)); // logs 12
price.value = 20;
flushSync(); // logs 24 - no microtask wait
Only compile(), applyBindings() and the custom-binding machinery touch document, and only when called. That makes
the graph usable for derived state on a server, in a queue worker, or in a test with no jsdom at all.
On a server-rendered page
This is the case the library was built for, and the pattern is three lines of infrastructure:
- The server renders the page, binding attributes and all.
- One module finds its mount point.
applyBindings()activates it.
<div data-app="search">
<input data-model="query.value">
<p data-bind-text="summary.value"></p>
</div>
<script type="module" src="/js/boot.js"></script>
// boot.js
const APPS = {search: () => import('./apps/search.js')};
for (const element of document.querySelectorAll('[data-app]')) {
const load = APPS[element.dataset.app];
if (load) load().then(module => module.mount(element));
}
Nothing is re-rendered, nothing is hydrated, and the page works as static HTML until the module lands.
Inside a Domma CMS site
Two details specific to Domma CMS, both of which this site relies on:
data-*attributes survive the markdown sanitiser untouched, so a page author can write binding attributes directly into page content with no plugin involved.- HTML comments do not survive it, so virtual bindings (
<!-- dm if: … -->) need markup that reaches the page after sanitisation - a plugin injection, or a transform onmarkdown:afterParse.
Load the library itself from a plugin's inject.bodyEnd snippet, or from public/js/, and mount as above.
Content Security Policy
There is no eval and no Function constructor anywhere in the package - asserted in the unit suite and against all
three built bundles. So this is enough:
Content-Security-Policy: default-src 'self'; script-src 'self'
No unsafe-eval. No unsafe-inline, provided your own bootstrap is in a file rather than in a <script> block. If you
load the bundle from a CDN, add that host to script-src.
Knockout, by comparison, compiles binding strings with new Function, which a policy like the above blocks outright.
The expressions here are parsed by hand instead - including, since 1.2, plain object and array literals - see
Expressions.
Tearing it down
An effect is a live node in the graph, and removing the DOM does not remove it. Every entry point hands you the means to stop it:
| Created by | Torn down by |
|---|---|
effect(fn) |
.dispose() on the returned Computation |
applyBindings(…) |
handle.dispose() |
compile(…) |
controller.destroy() |
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.
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.
Types
TypeScript declarations ship in the package - dist/domma-reactive.d.ts for import, dist/domma-reactive.d.cts for
require(), both wired through the exports map - so there is nothing to install from @types and nothing to
declare yourself:
import {observable, computed} from 'domma-reactive';
const count = observable(0); // Observable<number>
const doubled = computed(() => count.value * 2);
count.value = 'three'; // compile error - a number was expected
doubled.value = 4; // compile error - a computed with no write is read-only
The reference lists every signature.
Upgrading
Semantic versioning: a binding spelling or an export never changes inside a major. The
changelog records every release, and
npm run test:dist in the repository verifies all 33 exports through require(), import() and a <script> tag
before anything is published.
When it does not work
| What you see | What it means | What to do |
|---|---|---|
| Nothing happens at all, no warnings | applyBindings never ran, or ran against the wrong element |
Check the selector matched, and that the module actually loaded (network tab) |
{{name}} appears literally on the page |
applyBindings never interpolates mustache in existing DOM |
data-bind-text="name" |
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 |
| The list renders nothing, with a warning | No key= on data-each |
data-each="rows.value key=id" |
data-options renders no options |
You handed it the observable, not the array | data-options="groups.value" |
| A binding is silently skipped | Its expression did not parse | Look for the one warning - it names the expression and the template |
| Ticking a checkbox changes nothing | The field on the list item is not reactive | done: observable(false), and bind done.value |
| An edit inside a component never reaches the page | The param passed a copy | data-param-contact="$data", not data-param-name="name.value" |
Refused to evaluate a string as JavaScript in the console |
Something else on the page needs unsafe-eval |
Not this library - it contains no eval; check your other scripts |
| Effects keep running after the view is gone | Nothing was disposed | handle.dispose() |
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 typo cannot blank a page.